Building a Multi-Step Form in Rails (Part 4)

If you’ve been following along with this series of articles, you’ll be happy to know that we’ve built all the pieces we’ll need to create a server-side multi-step form within our Rails application. We have a way of maintaining state through SubscriptionCandidate. Our step models provide a way to process input and apply business logic. And we have a way of managing control flow with SubscriptionCreationProcess. Now we just need to do is put it all together by creating a controller that will guide our users through the process.

Let’s start by updating our routes.rb file. We’ll need to define three different routes. The first will be the route that kicks off the multi-step form. Since our form is meant to create a new Subscription record, we can use that as our resource

resources :subscription, only: [:new]

The next two routes will be for each individual step in the form—one to render the step, and the other to accept form submissions when a step is complete. The URLs will also have to include two identifying variables. A uuid to securely identify the SubscriptionCandidate we are updating. and the name of the current step.

Since our multi-step form is updating a SubscriptionCandidate at each step, we’ll name our controller after that resource. To keep our routes RESTful and semantic, let’s use an edit action to show a step (since that step is a form that updates the candidate) and an update action to submit a step. To make things more convenient, let’s also alias these routes as subscription_creation_step.

get "subscriptions/:uuid/step/:step_name,
  to: "subscription_candidates#edit"
  as: :subscription_creation_step

put "subscriptions/:uuid/step/:step_name",
  to: "subscription_candidate#update"

Now to set up the controllers. We’ll need to create a SubscriptionsController to start the process.

# app/controllers/subscriptions_controller.rb
class SubscriptionsController < ApplicationController
  def new
    candidate = SubscriptionCandidate.create!
    step_name = SubscriptionCreationProcess.new(candidate).current_step_name
    redirect_to subscription_creation_step_path(candidate.uuid, step_name)
  end
end
  

As you can see, when a user visits /subscriptions/new, a new subscription candidate will be created, and the user will be redirected to the first step of the form.

Now we need to set up the controller that will render the current step.

# app/controllers/subscription_candidates_controller.rb
class SubscriptionCandidatesController < ApplicationController
  def edit
    @step = subscription_creation_process.current_step
  end

  private

  def candidate
    @candidate ||= SubscriptionCandidate.find_by(uuid: params[:uuid])
  end

  def subscription_creation_process
    @subscription_creation_process ||= SubscriptionCreationProcess.new(candidate, params[:step_name])
  end
end

All we need to do in the edit action is render the form that gathers input for the current step. But before we create the view file, let’s complete our controller by adding the update action.

# app/controllers/subscription_candidates_controller.rb
class SubscriptionCandidatesController < ApplicationController
  def edit
    @step = subscription_creation_process.current_step
  end

  def update
    @step = subscription_creation_process.current_step
    if @step.update_candidate_and_complete(step_params)
      redirect_to_next_step
    else
      render "edit"
    end
  end

  private

  def candidate
    @candidate ||= SubscriptionCandidate.find_by(uuid: params[:uuid])
  end

  def subscription_creation_process
    @subscription_creation_process ||= SubscriptionCreationProcess.new(candidate, params[:step_name])
  end

  def step_params
    params.require(current_step_name).permit(*current_step_attributes)
  end

  def redirect_to_next_step
    uuid = candidate.uuid
    step_name = subscription_creation_process.next_step_name
    redirect_to subscription_creation_step_path(uuid, next_step_name)
  end

  def current_step_name
    subscription_creation_process.current_step_name
  end

  def current_step_attributes
    subscription_creation_process.current_step.attribute_names
  end
end

Updating the candidate is pretty standard. We try to update the model with the data received. If it is successful, we redirect to the next step. If it is unsuccessful, we render the current step. Notice, though, how we get our data. We are using a bit of metaprogramming to dynamically change what parameters are permitted so we are only updating the model with the current step’s attributes.

So what does our view file look like? Let’s update our app/views/subscription_candidates/edit.html.erb file to render the current step.

<%= render @step %>

Because we defined to_partial_path in our base step model, Rails will automatically know which partial to look for to render the step. Now let’s see what’s in that partial for our first step.

<% # app/views/subscription_creation_step/style.html.erb %>
<%
  form_for(
    @step,
    url: subscription_creation_step(@step.candidate.uuid, @step.step_name),
    as: @step.step_name,
    method: :put
  ) do |f|
%>  
  <h4>What kind of wathces do you prefer to wear?</h4>
  <label>
    <%= f.radio_button :preferred_style, Subscription::FORMAL %>
    I prefer timepieces with a formal look
  </label>
  <label>
    <%= f.radio_button :preferred_style, Subscription::CASUAL %>
    I prefer to wear watches that are more casual
  </label>
  <label>
    <%= f.radio_button :preferred_style, nil, checked: @step.preferred_style.nil? %>
    I don't really have a preference
  <label>
  <hr>
  <h4>Do you have a preference for the type of movement your watch uses?</h4>
  <label>
    <%= f.radio_button :preferred_movement_type, Subscription::QUARTZ %>
    I like to wear watches with quartz movements
  </label>
  <label>
    <%= f.radio_button :preferred_movement_type, Subscription::MECAHNICAL %>
    I like my watches to have mechanical movements
  </label>
  <label>
    <%= f.radio_button :preferred_movement_type, Subscription::AUTOMATIC %>
    I prefer my watches have automatic movements
  </label>
  <label>
    <%= f.radio_button, :preferred_movement_type, nil, checked: @step.preferred_movement_type.nil? %>
    As long as they keep time, the type of movement doesn't matter to me
  </label>
  <hr>
  <h4>Do you prefer watches of a certain size?</h4>
  <label>
    <%= f.radio_button :preferred_watch_size, Subscription::WATCH_SIZE_SMALL %>
    I like it when my watches are smaller
  </label>
  <label>
    <%= f.radio_button :preferred_watch_size, Subscription::WATCH_SIZE_MEDIUM %>
    I like watches that aren't too big, but aren't small either
  </label>
  <label>
    <%= f.radio_button :preferred_watch_size, Subscription::WATCH_SIZE_LARGE %>
    I like to make a statement by wearing large watches
  </label>
  <label>
    <%= f.radio_button :preferred_watch_size, nil, checked: @step.preferred_watch_size.nil? %>
    I'll wear watches of any size
  </label>
  <%= f.submit "Next" %>
<% end %>

Because our step model uses the behavior of ActiveModel, we can treat it like we would any other model in our view file. It can be used as an argument for form_for, and we can use ActionView::Helpers::FormHelper methods to create inputs for its attributes.

There are a few options we need to use in form_for to correctly submit the form to the server. We need to set the as option, since the parameters received by the server are namespaced under the step’s name. We also need to make sure the url option is set to the route of the current step, and the method is set to put.

With that, we’ve figured out how to create views for our steps. For bonus points, I’ll leave you to create the view files for the remaining steps.

All that’s left is to create controller logic for completing the form. What happens when the user submits the final step? Right now it will redirect to the next step, which doesn’t exist. Let’s change that. Remember how we defined a constant in SubscriptionCreationProcess that would signal that the last step had been completed? Before we redirect to the next step, let’s check to see if its name matches that constant. If it does, we should complete the candidate and redirect the user to a thank you page. Since our controller already has a private method that handles redirecting after a step has been completed, we’ll update that method.

# app/controllers/subscription_candidates_controller.rb
class SubscriptionCandidatesController < ApplicationController
  def edit
    @step = subscription_creation_process.current_step
  end

  def update
    @step = subscription_creation_process.current_step
    if @step.update_candidate_and_complete(step_params)
      redirect_to_next_step
    else
      render "edit"
    end
  end

  private

  def candidate
    @candidate ||= SubscriptionCandidate.find_by(uuid: params[:uuid])
  end

  def subscription_creation_process
    @subscription_creation_process ||= SubscriptionCreationProcess.new(candidate, params[:step_name])
  end

  def step_params
    params.require(current_step_name).permit(*current_step_attributes)
  end

  def redirect_to_next_step
    uuid = candidate.uuid
    step_name = subscription_creation_process.next_step_name
    if step_name == SubscriptionCreationProcess::COMPLETION_STEP_NAME
      candidate.complete
      redirect_to thank_you_path
    else
      redirect_to subscription_creation_step_path(uuid, next_step_name)
    end
  end

  def current_step_name
    subscription_creation_process.current_step_name
  end

  def current_step_attributes
    subscription_creation_process.current_step.attribute_names
  end
end

And now we have a fully functioning multi-step form!

And with that, our journey comes to an end. I hope you have found this series of articles helpful. For some, this may seem like a lot of work compared to adopting a front-end framework and using a library. But everyone has different technical requirements, and for those that maintain Rails applications, it is good to explore your options and know how you can leverage Rails to build necessary features.