homeASCIIcasts

217: Multistep Forms 

(view original Railscast)

Other translations: It Es

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 scaffold-generated new order form.

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.

The order form now only shows the first step.

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.

The form now has buttons for moving forwards and backwards.

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.

The first step no longer has a back button

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.

The final step of the form showing the saved information.

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.

The order has been saved.

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.

Only the error for the shipping step is shown.

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.

Likewise on the billing step only the error for the billing infomation field is shown.

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.