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

So far in our journey to create a server-side multi-step form in a Rails application, we have made two major strides. We have built a way to maintain state between steps through the SubscriptionCandidate, and we have built an interface for receiving input and managing that state. Now we need a way of organizing our steps.

In order to do that, we need to decide the ideal order of our steps. Let’s start by gathering information specific to our watch subscription service, then move onto gathering the user’s personal information. With that in mind, the order of our steps will be as follows

1Style Preferences
2Logistics
3 Address
4Signup
5Account Type
6Payment

That seems pretty straight-forward. So let’s add some complexity to it that requires some dynamic adaptation.

Let’s say after doing some research, we find that there are a good amount of people who are interested in our service, but are still skeptical of our ability to cater to their tastes. These potential users don’t want to risk paying a monthly fee for watches they won’t enjoy. To help convert these prospects into users (and eventually into paying customers), we decide to add a fourth type of account they can sign up for. Unlike our other three account types, this one is free. Instead of receiving a watch every month, they will receive an emailed with high-resolution photos and a detailed description of the watch we would have sent them based on their tastes. Hopefully, after a few months of tempting emails, they will decide to upgrade to a paid account.

Now the order of our steps is not so simple. If the user chooses a paid account in the Account Type step, the next step needs to be the Payment step. If they choose a free account, we don’t want to scare them off by requiring a credit card from them. So choosing a free account should complete the Subscription creation process completely.

Now that we have defined the order order of our steps, we need to build an object to manage that order. This object should have three main responsibilities:

  1. Determine what the current step is, and return the model for that step
  2. Determine what the previous step is
  3. Determine what the next step is

Because this is supervising the creation process, let’s call it our SubscriptionCreationProcess. Let’s start by building it’s ability to determine the current step.

# /lib/subscription_creation_process.rb
class SubscriptionCreationProcess
  def initialize(candidate, current_step_name = SubscriptionCreationStep::Style.step_name)
    @candidate = candidate
    @current_step = build_step_from_candidate(current_step_name)
  end

  attr_reader :candidate, :current_step

  private

  def build_step_from_candidate(current_step_name)
    step_klass = "SubscriptionCreationStep::#{current_step_name.camelize}".constantize
    step_klass.new(candidate)
  end
end

Here we are initializing the supervisor object with access to the candidate. This will will help the supervisor build the current step, among other things. We are also initializing the object with the name of the current step. It will use this string to initialize the current step’s model. If no step name is given, it will assume the user is on first step in the process (the Style Preferences step).

Next, let’s add the ability to determine what the next step should be.

# /lib/subscription_creation_process.rb
class SubscriptionCreationProcess
  COMPLETION_STEP_NAME = "complete"

  def initialize(candidate, current_step_name)
    @candidate = candidate
    @current_step = build_step_from_candidate(current_step_name)
  end

  attr_reader :candidate, :current_step

  def current_step_name
    current_step.step_name
  end

  def next_step_name
    next_step_sequence[current_step_name]
  end

  private

  def build_step_from_candidate(current_step_name)
    step_klass = "SubscriptionCreationStep::#{current_step_name.camelize}".constantize
    step_klass.new(candidate)
  end

  def next_step_sequence
    {
      SubscriptionCreationStep::Style.step_name => SubscriptionCreationStep::Logistics.step_name,
      SubscriptionCreationStep::Logistics.step_name => SubscriptionCreationStep::Address.step_name,
      SubscriptionCreationStep::Address.step_name => SubscriptionCreationStep::Signup.step_name,
      SubscriptionCreationStep::Signup.step_name => SubscriptionCreationStep::AccountType.step_name,
      SubscriptionCreationStep::AccountType.step_name => determine_step_after_account_type,
      SubscriptionCreationStep::Payment.step_name => COMPLETION_STEP_NAME
    }
  end

  def determine_step_after_account_type
    if candidate.account_type == Signup::FREE
      COMPLETION_STEP_NAME
    else
      SubscriptionCreationStep::Payment.step_name
    end
  end
end

Here, we are adding a private hash called next_step_sequence. Each key in this hash is the name of the current step, and it’s value is the name of the next step. This way, we can use the current step to check what the next step will be. If the next step is meant to be dynamic, instead of a static string, the value can be set to a method that is called. This method can then check the state of the candidate to determine the next step. In our case, it is checking to see if the account chosen by the user is a free account. If it is, the next step is a string that will signal the completion of the process. If it is a paid account, it will return the name of the Payment step.

We can employ a similar pattern to determine what the previous step should be.

# /lib/subscription_creation_process.rb
class SubscriptionCreationProcess
  COMPLETION_STEP_NAME = "complete"

  def initialize(candidate, current_step_name)
    @candidate = candidate
    @current_step = build_step_from_candidate(current_step_name)
  end

  attr_reader :candidate, :current_step

  def current_step_name
    current_step.step_name
  end

  def next_step_name
    next_step_sequence[current_step_name]
  end

  def previous_step_name
    previous_step_sequence[current_step_name]
  end

  private

  def build_step_from_candidate(current_step_name)
    step_klass = "SubscriptionCreationStep::#{current_step_name.camelize}".constantize
    step_klass.new(candidate)
  end

  def next_step_sequence
    {
      SubscriptionCreationStep::Style.step_name => SubscriptionCreationStep::Logistics.step_name,
      SubscriptionCreationStep::Logistics.step_name => SubscriptionCreationStep::Address.step_name,
      SubscriptionCreationStep::Address.step_name => SubscriptionCreationStep::Signup.step_name,
      SubscriptionCreationStep::Signup.step_name => SubscriptionCreationStep::AccountType.step_name,
      SubscriptionCreationStep::AccountType.step_name => determine_step_after_account_type,
      SubscriptionCreationStep::Payment.step_name => COMPLETION_STEP_NAME
    }
  end

  def previous_step_sequence
    {
      SubscriptionCreationStep::Payment.step_name => SubscriptionCreationStep::AccountType.step_name
      SubscriptionCreationStep::AccountType.step_name => SubscriptionCreationStep::Signup.step_name,
      SubscriptionCreationStep::Signup.step_name => SubscriptionCreationStep::Address.step_name,
      SubscriptionCreationStep::Address.step_name => SubscriptionCreationStep::Logistics.step_name,
      SubscriptionCreationStep::Logistics.step_name => SubscriptionCreationStep::Style.step_name,
    }
  end

  def determine_step_after_account_type
    if candidate.account_type == Signup::FREE
      COMPLETION_STEP_NAME
    else
      SubscriptionCreationStep::Payment.step_name
    end
  end
end

And with that, we have our steps all in order. The only thing left to do now is build a Rails controller to put it all together. We’ll tackle that in the next, and final, article.