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

In the previous article, we established how we can persist data between steps using an entity candidate. Now we need to build the interface that will receive input and update the database record.

The first thing we need to do is look at all of the questions we expect to ask the user, and group them into steps. For our watch subscription business, the we want to ask the following questions of our user:

  • What is your delivery address? (street, city, state, and zip code)
  • What is your wrist size?
  • What size watch do you prefer?
  • What type of watch movement do you prefer?
  • Do you watches that are formal or casual?
  • How often would you like to receive a new watch?
  • How much would you be willing to keep a watch?
  • What is your name?
  • What email would you like to sign up with?
  • What password would you like to sign up with?
  • What kind of account would you like to sign up for?
  • What is your payment information?

Ideally, we want to reduce the amount of complexity in each step by only asking the user questions that are closely related. Looking at our list of questions, we can probably split our signup funnel into the following steps:

1Style – Do you prefer formal or casual watches?
– What type of watch movement do you prefer?
– What size watch do you prefer?
2Logistics– What is your wrist size?
– How often would you like to receive a new watch?
– What would you be willing to pay to keep a watch?
3Address– What is your street?
– What is your city?
– What is your state?
– What is your postal code?
4Signup– What is your name?– What password would you like to use?
5Account Type– What account type would you like to sign up for?
6Payment– What is your CC number?
– What is the name on the card?
– What is the CVC?
– What is the expiry?

Now that we have broken up all of our questions into individual steps, we need to build an interface that will process input from the user at each step. This interface should have the following responsibilities:

  1. Receive input from the user.
  2. Validate that input.
  3. If the input is valid, it should update the candidate. If the input is not valid, it should return error messages
  4. Apply any business logic that is needed when updating the candidate with the input received.

Looking at those responsibilities, it is easy to see that we need an interface that behaves similarly to a Rails model. Lucky for us, we can include most of that behavior in our interface with existing Rails modules.

Because each step will be expecting different input, and will beed to be able to apply its own business logic, we will have to create a different model for every step. However, we should still establish a baseline API that dictates how these models should be used. So we will need to create a parent class that all of our step models inherit from. It should look a little something like this:

# lib/subscription_creation_step/base.rb
module SubscriptionCreationStep
  class Base
    include ActiveModel::Model
    include ActiveModel::Attributes

    def self.step_name
      name.split("::").last.underscore
    end

    def step_name
      self.class.step_name
    end

    def initialize(candidate)
      @candidate = candidate
      attributes_from_candidate = candidate.attributes.stringify_keys
      step_attribute_keys = self.class.attribute_types.stringify_keys.keys
      step_attributes = attributes_from_candidate.slice(*step_attribute_keys)
      super(step_attributes)
    end

    attr_reader :candidate

    def update_candidate_and_complete(attrs)
      assign_attributes(attrs)
      if valid?
        complete
      else
        false
      end
    end

    def complete
      candidate.update(candidate_attributes)
    end

    def candidate_attribute_names
      candidate_attributes.keys
    end

    def candidate_attributes
      attributes
    end

    def to_partial_path
      "subscription_creation_step/#{step_name}"
    end
  end
end

Let’s go through this and explain how each part of this parent class will influence how we create and use the step models.

    include ActiveModel::Model
    include ActiveModel::Attributes

We want to include behavior that Rails models use to dictate how our steps will create and maintain their own internal state. The main benefit of including ActiveModel::Model is that it gives us access to validation behavior, along with some behavior related to attributes. ActiveModel::Attributes makes our ability to set and manipulate the step’s attributes much more robust. Ultimately, we want to definitively state what data we expect each step to handle. By defining attributes on individual steps, we can control what input a step will receive and validate.

    def self.step_name
      name.split("::").last.underscore
    end

    def step_name
      self.class.step_name
    end

Here we are employing a little bit of metaprogramming to help create a universal identifier for each step based on the class name.

    def initialize(candidate)
      @candidate = candidate
      attributes_from_candidate = candidate.attributes.stringify_keys
      step_attribute_keys = self.class.attribute_types.stringify_keys.keys
      step_attributes = attributes_from_candidate.slice(*step_attribute_keys)
      super(step_attributes)
    end

Here we are using a some of the functionality from ActiveModel::Model and ActiveModel::Attributes to set the step’s initial state. We need to initialize each step with the candidate it will be updating. When we do that, we check to see if the candidate has any attributes in common with the step, and initializes the step with those values.

    def update_candidate_and_complete(attrs)
      assign_attributes(attrs)
      if valid?
        complete
      else
        false
      end
    end

Here we are creating a public method that updates the candidate and completes the step. Much like ActiveRecord’s update method, this validates the input, and only updates the database if validations pass.

    def complete
      candidate.update(candidate_attributes)
    end

When we complete a step, we want to update the candidate. If there is any other business logic that needs to be applied when completing a step, a child class can redefine this method and call super to add that business logic to the completion process. Note that when we update the candidate, we are not using the step’s attributes. Instead, we are using attributes that are defined elsewhere in the step.

    def candidate_attribute_names
      candidate_attributes.keys
    end

    def candidate_attributes
      attributes
    end

Remember when I said we aren’t updating the candidate with the step’s attributes? Well, that was a bit of a lie. We update the candidate with the attributes returned from candidate_attributes. By default, this method returns the step’s attributes. However, this method can be redefined in a child class to return different attributes depending on what data we want to save to the candidate.

    def to_partial_path
      "subscription_creation_step/#{step_name}"
    end

Here we are leveraging a Rails convention that will make rendering the step in a view file much easier. By defining to_partial_path, we are defining which partial file in our views directory should be used when an instance of the step is given as the first argument to the render method. This will become clearer when we make our views and controller.

Now that we have defined our base class, lets put it into practice by creating a step model. We’ll start simple by defining the step that updates a user’s style preferences.

# /lib/subscription_creation_step/style.rb
module SubscriptionCreationStep
  class Style < Base
    attribute :preferred_style
    attribute :preferred_movement_type
    attribute :preferred_watch_size

    validates(
      :preferred_style,
      inclusion: { in: Subscription::STYLES },
      allow_blank: true
    )
    validates(
      :preferred_movement_type,
      inclusion: { in: Subscription::MOVEMENT_TYPES },
      allow_blank: true
    )
    validates(
      :preferred_watch_size,
      inclusion: { in: Subscription::WATCH_SIZES },
      allow_blank: true
    )
  end
end

That’s all there is to defining a step in its simplest form. By defining the step’s attributes, we are declaring that we are only expecting to receive preferred_style, preferred_movement_type, and preferred_watch_size in this step. Because our SubscriptionCandidate has all of these attributes, we can fallback on the step’s default behavior to update those attributes on completion. Furthermore, because we have defined validations for these attributes, we can be sure that the candidate won’t update with invalid data, and the user will have access to error messages.

Now that we’ve seen what a simple step looks like, let’s explore a more complicated scenario. You’ll notice that our candidate has a relationship to Address, and only stores the address_id. However, in the address step of our form, we need to ask the user for all of their address details. Let’s start by setting up a step that collects those details.

# /lib/subscription_creation_step/address.rb
module SubscriptionCreationStep
  class Address < Base
    attribute :street_1
    attribute :street_2
    attribute :city
    attribute :state
    attribute :zip_code
  end
end

Let’s assume that we have already defined a complete Address model to handle business logic related to creating an address. So instead of recreating that behavior here, let’s just borrow it from the existing model. When our step receives data, we can use the step’s attributes to build an address and save its ID to the candidate. We can do this by redefining the candidate_attributes method.

# /lib/subscription_creation_step/address.rb
module SubscriptionCreationStep
  class Address < Base
    attribute :street_1
    attribute :street_2
    attribute :city
    attribute :state
    attribute :zip_code

    def candidate_attributes
      { address: address_with_assigned_attributes }
    end

    private

    def address_with_assigned_attributes
      address.assign_attributes(attributes)
      address
    end

    def address
      @address ||= ::Address.new
    end
  end
end

Now whenever we call complete on our step, step’s attributes will be assigned to an instance of Address, and save that address to the candidate.

Now all we are missing are validations. Once again, this is behavior we can borrow from the existing Address model.

# /lib/subscription_creation_step/address.rb
module SubscriptionCreationStep
  class Address < Base
    attribute :street_1
    attribute :street_2
    attribute :city
    attribute :state
    attribute :zip_code

    validate :address_is_valid

    def candidate_attributes
      { address: address_with_assigned_attributes }
    end

    private

    def address_is_valid
      unless address_with_assigned_attributes.valid?
        address.errors.each do |attribute, error_message|
          errors.add(attribute, error_message)
        end
      end
    end

    def address_with_assigned_attributes
      address.assign_attributes(attributes)
      address
    end

    def address
      @address ||= ::Address.new
    end
  end
end

Now, if there is any invalid attributes, we can imprint those errors from the Address model, right onto the step model.

Now all that’s left is to create a model for each or our remaining steps. I’ll include those models below, but I encourage you to try building them yourselves (assume that there are already models defined for User and PaymentSource). Once you’ve got the hang of it, you will be ready to move on to the next part of the process: building a supervisor to manage the order of steps. We’ll learn more about that in the next article.

The Remaining Steps

# /lib/subscription_creation_step/logistics.rb
module SubscriptionCreationStep
  class Logistics < Base
    attribute :wrist_size
    attribute :delivery_period_length
    attribute :value_limit

    validates :wrist_size, presence: true
    validates :delivery_period_length, presence: true
    validates(
      :value_limit,
      numericality: { greater_than_or_equal_to: 100,  only_integer: true },
      allow_blank: true
    )
  end
end
# /lib/subscription_creation_step/signup.rb
module SubscriptionCreationStep
  class Signup < Base
    attribute :name
    attribute :email
    attribute :password

    validate :user_is_valid

    def candidate_attributes
      { user: user_with_assigned_attributes }
    end

    private

    def user_is_valid
      unless user_with_assigned_attributes.valid?
        user.errors.each do |attribute, error_message|
          errors.add(attribute, error_message)
        end
      end
    end

    def user_with_assigned_attributes
      user.assign_attributes(attributes)
      user
    end

    def user
      @user ||= User.new
    end
  end
end
# /lib/subscription_creation_step/account_type.rb
module SubscriptionCreationStep
  class AccountType < Base
    attribute :account_type

    validates(
      :account_type,
      presence: true,
      inclusion: { in: Subscription::ACCOUNT_TYPES }
    )
  end
end
# /lib/subscription_creation_step/payment.rb
module SubscriptionCreationStep
  class Payment < Base
    attribute :credit_card_number
    attribute :name_on_card
    attribute :expiry
    attribute :cvc

    validates :payment_source_is_valid

    def candidate_attributes
      { payment_source: payment_source_with_assigned_attributes }
    end

    private

    def payment_source_is_valid
      unless payment_source_with_assigned_attributes.valid?
        payment_source.errors.each do |attribute, error_message|
          errors.add(attribute, error_message)
        end
      end
    end

    def payment_source_with_assigned_attributes
      payment_source.assign_attributes(attributes)
      payment_source
    end

    def payment_source
      @payment_source ||= PaymentSource.new
    end
  end
end