217: Multistep Forms
(view original Railscast)
A multistep form, also known as a wizard, is a large form that has been split up into a series of pages that users can navigate through to complete the form. Not everyone is a fan of this approach, some people prefer to show one large form or to split a long form up into separate resources and models. There are, however, some cases when this is the best approach, for example an online tax return that requires a lot of user input and branching of steps and therefore makes sense to implement as a multistep form.
A good multistep form remembers the input between steps, allowing users to go backwards and forwards through the pages without losing any of the information they have entered. If you just want to break up a big form to simplify the user interface you may could use a combination of JavaScript and CSS to show and hide different parts of the form when the “previous” and “next” buttons are pressed. An example of this approach can be found of Apple’s developer site. If you need something more dynamic and server-side orientated then you’ll need to go through Rails itself and we’ll show you how to do that in this episode.
A Multistep Order Form
To demonstrate a multistep form we’re going to add a checkout process to a store application. Users will first fill in their shipping information and then their billing information and finally, on the third step, see a summary of their order where they can confirm it.
We don’t have an order model or controller yet so we’ll use one of Ryan Bates’ nifty generators to create a scaffold for the order form. Note that this application is written in Rails 2 so we can use script/generate
. A real order model would have many attributes, but for the purposes of this example we’ll just have two. We’ll also create index
, show
and new
actions for the controller.
$ script/generate nifty_scaffold order shipping_name:string billing_name:string index show new
Then we’ll migrate the database to create the new database table.
$ rake db:migrate
The order form as generated by the scaffold has all the fields together on one page so the first thing we need to do is to split it up into multiple steps.
The code for the new order form has all of the form fields together. To begin to make our multistep form we’ll move each of the three paragraph elements and the form fields they wrap out into a separate partial.
/app/views/orders/new.html.erb
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <p> <%= f.label :shipping_name %><br /> <%= f.text_field :shipping_name %> </p> <p> <%= f.label :billing_name %><br /> <%= f.text_field :billing_name %> </p> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Before we do that, though we’ll add a heading to each section and alter the final section so that it shows a summary of the order.
/app/views/orders/new.html.erb
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <h2>Shipping Information</h2> <p> <%= f.label :shipping_name %><br /> <%= f.text_field :shipping_name %> </p> <h2>Billing Information</h2> <p> <%= f.label :billing_name %><br /> <%= f.text_field :billing_name %> </p> <h2>Confirm Information</h2> <p> <strong>Shipping Name:</strong> <%= h @order.shipping_name %> </p> <p> <strong>Billing Name:</strong> <%= h @order.billing_name %> </p> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
With that done we can now move each section into its own partial. We’ll create three new partials called shipping_step, billing_step and confirmation_step and copy the relevant parts of the form into them. After doing that our new order form will look like this:
/app/views/orders/new.html.erb
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render 'shipping_step', :f => f %> <%= render 'billing_step', :f => f %> <%= render 'confirmation_step', :f => f %> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
And the three partials will look like this:
/app/views/orders/_billing_step.html.erb
<h2>Billing Information</h2> <p> <%= f.label :billing_name %><br /> <%= f.text_field :billing_name %> </p>
/app/views/orders/_shipping_step.html.erb
<h2>Shipping Information</h2> <p> <%= f.label :shipping_name %><br /> <%= f.text_field :shipping_name %> </p>
/app/views/orders/confirmation_step.html.erb
<h2>Confirm Information</h2> <p> <strong>Shipping Name:</strong> <%= h @order.shipping_name %> </p> <p> <strong>Billing Name:</strong> <%= h @order.billing_name %> </p>
We’ll need to make a change to the Order
model so that it knows about each step and can identify which one is the current step. We could use a state machine plugin for doing this, but as this example is fairly simple we’ll write the code from scratch.
The Order
model will need to know what the steps are and the order they should be in and so we’ll write a new steps method to return a list of the steps. Our list will be a simple array but for more complex forms this method easily be more dynamic and handle cases where the list of steps changes depending on how a give step was answered.
To get and set the current step for an order we’ll create a writer called current_step
and an equivalent accessor method that will return the current step if this has been set or the first step otherwise. After making these changes the Order
model will look like this:
/app/models/order.rb
class Order < ActiveRecord::Base attr_accessible :shipping_name, :billing_name attr_writer :current_step def current_step @current_step || steps.first end def steps %w[shipping billing confirmation] end end
Now that the Order
model knows which step is the current one we can use that information to dynamically change the partial that is rendered by modifying the new order form slightly so that it only shows the current step.
/app/views/orders/new.html.erb
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render "#{@order.current_step}_step", :f => f %> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
When we reload the order form now we’ll see just the first step rendered.
Moving Through The Steps
If we click the submit button on the form it will call the create
action and create a new order but that isn’t what we want to happen. Instead the next step of the form should be rendered and we’ll need to change the controller’s behaviour so that it does that.
There are a number of ways we can approach this problem. We could create a separate action for each of the steps; we could just use the create
action for the initial step and the edit
and update
actions for the other steps which would mean that we have a partially-completed model in the database or we could stay in the new
and create
actions and store the details that have been entered so far in the session. There are some downsides to this approach and we’ll mention these and show the alternatives as we go along.
The simplest thing we can do that would work is to modify the create
action. The first step we’ll take will be to change it so that it doesn’t save the order but instead shows the next step of the form.
/app/controllers/orders_controller.rb
def create @order = Order.new(params[:order]) @order.next_step render 'new' end
In the controller we’re calling a next_step
method on Order
that we’ll have to write in the model code.
/app/models/order.rb
def next_step self.current_step = steps[steps.index(current_step)+1] end
Now we’ll be taken from the first step to the second when we click the submit button on the form but when we click the button again we’ll remain on the second step. This is because we’re not recording the current step when the form is submitted. In the create
action we need to set the current step in the Order
model and then save that value. We can do this by getting the current step from the session, moving to the next one and then storing the next step back in the session.
/app/controllers/orders_controller.rb
def create @order = Order.new(params[:order]) @order.current_step = session[:order_step] @order.next_step session[:order_step] = @order.current_step render 'new' end
When we try the form now it will step through the pages as it should and wrap back round to the first step when we submit the final step. This isn’t quite what we’re after but we’ll write the code to handle the final step later.
Before we do that, we’ll implement a way to go back through the steps. The form currently allows us to move forward but we can’t go back and make changes to the fields we’ve already completed. We’ll add another button to the form so that we can do that.
/app/views/orders/new.html.erb
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render "#{@order.current_step}_step", :f => f %> <p><%= f.submit "Continue" %></p> <p><%= f.submit "Back", :name => "previous_button" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Note that we’ve given the back button a name
attribute so that we can determine which button was used to submit the form. We can now modify the controller to behave appropriately depending on which button was pressed.
/app/controllers/orders_controller.rb
def create @order = Order.new(params[:order]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step else @order.next_step end session[:order_step] = @order.current_step render 'new' end
For this to work we’ll need a previous_step
method in the Order
model which will be similar to the next_step
method we wrote before.
/app/models/order.rb
def previous_step self.current_step = steps[steps.index(current_step)-1] end
We now have buttons on the form that move us through the steps in both directions.
Having a back button on the first page doesn’t make sense so we’ll hide it if the order is on the first step.
/app/views/orders/new.html.erb
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render "#{@order.current_step}_step", :f => f %> <p><%= f.submit "Continue" %></p> <p><%= f.submit "Back", :name => "previous_button" unless @order.first_step? %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
For this to work we’ll have to add a first_step?
method to the Order
model.
/app/models/order.rb
def first_step? current_step == steps.first end
When we reload the form now and we won’t have a back button on the first step.
Making Fields Remember Their Values
We can now step backwards and forwards through our form but if we enter a value in one of the fields it isn’t remembered if we go back to that step later. We can solve this problem by using the session again. While it isn’t recommended to store complex objects in session variables, simple objects such as hashes and arrays are fine.
The first step to doing this is to create a new session variable in the new
action called order_params
and to set its value to an empty hash unless it already exists.
/app/controllers/orders_controller.rb
def new session[:order_params] ||= {} @order = Order.new end
In the create
action we’ll merge the values in the order parameters with the values from the session variable. Note that we’re using a deep merge in case there are any nested values in the order parameters and only performing the merge if the order parameters exist. We can then create the new Order
object from this merged hash.
/app/controllers/orders_controller.rb
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step else @order.next_step end session[:order_step] = @order.current_step render 'new' end
Now when we fill in the form we’ll see the values we entered on the final step, showing that they are stored between steps.
There is still a slight problem, however. If we move away from the form while it is partially completed and then go back to it afterwards the information we entered and the step we were on are lost. We can solve this by copying the two lines that get the order information and current step from the session variables we created from the create action to new.
/app/controllers/orders_controller.rb
def new session[:order_params] ||= {} @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] end
With this code in place we can now return to the form and any data we’ve entered will still be there. Not only that but we’ll be taken straight to the last step we were on.
Saving an Order
Now we’ll make the form fully-functional by making it save the order when the last step is completed. We can do this by modifying the controller so that it saves the order only if the current step is the last one and if the button pressed wasn’t the “previous step” button. We’ll also need to change the rendering behaviour so that if the order is saved the application redirects to the order’s show action and displays a flash message.
/app/controllers/orders_controller.rb
class OrdersController < ApplicationController def index @orders = Order.all end def show @order = Order.find(params[:id]) end def new session[:order_params] ||= {} @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] end def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step elsif @order.last_step? @order.save else @order.next_step end session[:order_step] = @order.current_step if @order.new_record? render 'new' else flash[:notice] = "Order saved." redirect_to @order end end end
To make this work we’ll also have to add a last_step
method to the Order
model.
/app/models/order.rb
def last_step? current_step == steps.last end
When we go the form now and fill in each step the order will be saved and we’ll be redirected to the page for that order.
We’re not quite there yet, though. If we try to create another new order the form will go straight to the final step and we’ll see the details from the previous order. We’ll need to change the controller again so the order information is cleared when the order is successfully saved. We can do that by clearing the session information when the order is saved by setting both the order_step
and order_params
session variables to nil
.
/app/controllers/orders_controller.rb
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step elsif @order.last_step? @order.save else @order.next_step end session[:order_step] = @order.current_step if @order.new_record? render 'new' else session[:order_step] = session[:order_params] = nil flash[:notice] = "Order saved." redirect_to @order end end
Adding Validation
The last thing we’ll cover is how to deal with validations in the form. We’ll give the Order
model validation on the shipping_name
and billing_name
attributes.
/app/models/order.rb
validates_presence_of :shipping_name validates_presence_of :billing_name
We only want the validation error for each attribute to appear on the appropriate step and we can do this by adding an if
condition to the validators.
/app/models/order.rb
validates_presence_of :shipping_name, :if => lambda { |o| o.current_step == "shipping" } validates_presence_of :billing_name, :if => lambda { |o| o.current_step == "billing" }
We only want the form to change to the next (or previous) step if the order is valid and so we’ll need to modify the create
action in the controller again so that checks that the order is valid. We do this by wrapping the code that changes step and saves the record in an if
statement.
/app/controllers/orders_controller.rb
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if @order.valid? if params[:back_button] @order.previous_step elsif @order.last_step? @order.save else @order.next_step end session[:order_step] = @order.current_step end if @order.new_record? render 'new' else session[:order_step] = session[:order_params] = nil flash[:notice] = "Order saved." redirect_to @order end end
If we try to create a new order now and try to submit the first step we’ll see the error shown for the shipping field but not for the billing field.
Likewise if we then move on to the billing step and try to leave that blank we’ll see only the error for that step.
We could tidy the validation up a little by moving the lambda expressions into methods called shipping?
and billing?
, like this.
/app/models/order.rb
class Order < ActiveRecord::Base attr_accessible :shipping_name, :billing_name attr_writer :current_step validates_presence_of :shipping_name, :if => :shipping? validates_presence_of :billing_name, :if => :billing? # other methods omittted. def shipping? current_step == "shipping" end def billing? current_step == "billing" end end
It’s also useful to have a method that validates all of the steps at once. To do this we can write an all_valid?
method that loops through each step and checks that it is valid.
/app/models/order.rb
def all_valid? steps.all? do |step| self.current_step = step valid? end end
This way if we ever make changes to the validations or to the way the steps work we can ensure that no steps were invalidated in the process. We can use this new method in the controller to make sure that the order is only saved if all of the steps are valid.
/app/controllers/orders_controller.rb
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if @order.valid? if params[:back_button] @order.previous_step elsif @order.last_step? @order.save if @order.all_valid? else @order.next_step end session[:order_step] = @order.current_step end if @order.new_record? render 'new' else session[:order_step] = session[:order_params] = nil flash[:notice] = "Order saved." redirect_to @order end end
The handy thing about the all_valid?
method is that it changes the step it’s on so that if one of the steps isn’t valid then that will become the current step so that the user is shown the first invalid step.
That’s it for this episode on multistep forms. One thing to bear in mind is that we are storing the order parameters in a session which means that if a user has multiple windows or tabs open then the same session information will be shared across them. If you want the multiple tabs or windows to be treated separately then you might want to store the order parameters in the database and save the order model early, using the update action to save the various steps. Alternatively the parameters could be stored in hidden fields on the form.
Finally, a quick look at the create action shows that it’s rather longer and more complicated than a method should be. While it’s fine for the purposes on this demonstration, if this technique was to be put to use in a production application it would need some refactoring to tidy it up.