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:
1 | Style |
– Do you prefer formal or casual watches? – What type of watch movement do you prefer? – What size watch do you prefer? |
2 | Logistics | – 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? |
3 | Address | – What is your street? – What is your city? – What is your state? – What is your postal code? |
4 | Signup | – What is your name? |
5 | Account Type | – What account type would you like to sign up for? |
6 | Payment | – 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:
- Receive input from the user.
- Validate that input.
- If the input is valid, it should update the candidate. If the input is not valid, it should return error messages
- 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