How to create a multi-step form in Ruby on Rails
Recently at work, I had to implement a sign-up form for user subscriptions. At first glance, it seemed really straightforward: create a form where a user enters their information and upon successful submission, decrease the number of subscription licenses associated with that user's account. Unsurprisingly, however, there were a lot of other requirements I had to meet and implementing this functionality turned out to be more complicated than I was expecting. For the sake of future me, and for the sake of whoever else might need to do something similar, I wanted to write out my approach to implementing the feature in this post. First things first, let's make sure we understand all the requirements... I'll list them all below!
The Requirements to be met
- User gets an email with a link they can go to in order to make a subscription.
- The link includes a query parameter that ties the subscription to an account (which holds X number of licenses for subscriptions).
- On the sign-up form, if the user already exists in the system by checking their email redirect the user to log in before continuing so we have access to their other information for the next part of the form (first name, last name, organizations).
- If the user already exists, disable the user from editing their first_name, last_name, etc from this form to keep data accurate.
- If the user does not exist, send the user an email to confirm their account in addition to creating the subscription.
- After successful completion of the sign-up, reduce the number of licenses on the account.
Models
For context, these are the models that I am working with and the relevant data attributes for each:
User
model: a user has anemail
,first_name
,last_name
, has manyaccounts
Account
model: an account has N number oflicenses
for subscriptions, has many organizationsOrganization
model: has a name, belongs toaccounts
and has manyusers
The Solution
From the list of requirements, it is clear that a simple form wont suffice. The approach I took instead was to create a multi-step form where the first step of the form validates the email
and the second step of the form validates the rest of the information about the User
. Since we need to validate parts of the same object at different times, I also created two new objects that inheret from ActiveModel::Model
to be able to add validations on the separate concerns.
In this post, I will explain to you how I created a multi-step form in Ruby on Rails.
The Tutorial
First, we will create the model classes to implement our validations. We need one class to validate the user account via email
and another to validate the user details such as first_name
and last_name
.
These classes will be used in each step of our form to validate the data at each step (but not necessarily write the data to the database until all validations are complete):
- Step 1 - Validate Email of User
- Step 2 - Validate Details of User
One way to do this is to create a class which inherets from ActiveModel::Model
. This enables us to validate the object and plug it into a Rails form.
The User Accounts Form Model
First, create the UserAccountForm
class:
class UserAccountForm
include ActiveModel::Model
attr_accessor :email, :user, :account_uuid
validates :email, presence: true, format: { with: URI::MailTo::EMAIL }
def user
User.find_by(email: email)
end
def account
Account.where(uuid: account_uuid).first
end
end
The User Accounts Form Controller
Then, create the UserAccountsController
:
class UserAccountsController < ApplicationController
def new
# the :account_uuid is passed in the request parameters from the sign-up link
@user_account = UserAccountForm.new(account_uuid: params[:account_uuid])
# if a user is currently logged in, skip to the next part of the form
# passing their email and user_id through the request with query params
if current_user
params = {
user_profile_form: {
mail: current_user.email,
user_id: current_user.id,
account_uuid: @user_account.account_uuid
}
}
redirect_to new_user_profile_path params
else
render :new
end
end
def create
@user_account = UserAccount.new(user_account_params)
if @user_account.user.present? && @user_account.user != current_user
# If we find a user with this email and that user is not currently
# logged in, we redirect them to log in before continuing
notice = "Looks like there's already an account associated with this email. Please log in to continue."
redirect to new_user_session_path, notice: notice
elsif @user_account.valid? && @user_account.user.present && @user_account user == current_user
# Instead of persisting the values to the `User` object,
# we pass them through to the next part of the form
# to fill out hidden fields for those attributes
params = {
user_profile_form: {
email: @user_account.email,
user_id: @user_account.user.id,
account_uuid: @user_account.account_uuid
}
}
redirect_to new_user_profile_path params
else
render :new
end
end
private
def user_account_params
params.require(:user_account_form).permit(:account_uuid, :email)
end
end
The User Accounts Form View
Next, we create the User Accounts Form view. In the example below, I am using haml.
# /app/views/user_accounts/new.html.haml
%h1 Subscription - Sign Up
= simple_form_for(@user_account, url: user_accounts_path) do |f|
= f.error_notification
= f.input :account_uuid, as: :hidden
= f.input :email, placeholder: "Email"
%p.hint Please use the same email address you've used for other products on this site.
= f.button :submit, "Next"
The User Profile Form Model
Now we move on to creating the classes for the next step of the form, the User Profile.
class UserProfileForm
include ActiveModel::Model
attr_accessor :first_name, :last_name, :email, :organization, :account_uuid
validates :first_name, presence: true
validates :last_name, presence: true
validates :email, presence: true
validates :organization, presence: true
validates :account_uuid, presence: true
end
The User Profile Controller
The User Profile controller differs slightly from the User Account controller because it is the last step in our form. We will need to use the parameters passed in the request from the UserAccountController
to fill in the hidden form fields so that the data can be submitted in the final request. I've created a helper method set_inputs
that will create instance variables that I can access in my view to set the input values if we find any data on the user.
class UserProfileController < ApplicationController
before_action :set_inputs # our function to set the hidden field inputs
def new
@user_profile = UserProfileForm.new
render :new
end
def create
@user_profile = UserProfileForm.new(user_profile_params)
if !@user_profile.valid?
render :new, alert: "Some required fields are missing"
else
if @user
SubscriptionUser.create!(user: @user, account: @account )
else
# we put it in a transaction because we do not want to create the subscription unless the user creation succceeds
User.transaction do
begin
if @user.nil?
@new_user = true
@user = User.create!(
email: user_profile_params[:email],
first_name: user_profile_params[:first_name],
last_name: user_profile_params[:last_name],
)
NewUserMailer.send_new_user_email(@user).deliver_later
end
@subscribed_user = SubscriptionUser.create!(
user: @user,
account: @account,
organization: user_profile_params[:organization]
)
rescue ActiveRecord::RecordInvalid
render :new, notice: "An issue occurred with creating your subscription and we were unable to save it. Please refresh and try again.."
else
if @new_user
notice = "Please check your email to confirm your email address and set your password."
redirect_to new_user_session_path, notice: notice
else
notice = "Success! Your subscription has been created."
redirect_to root_path, notice: notice
end
end
end
end
end
end
private
def set_inputs
@user = current_user ? current_user : nil # if there is a user logged in, set our @user object to the current_user, otherwise set it to nil
@email = user_profile_params[:email]
@first_name = current_user&.first_name # &. is the safe navigation operator, it will only make the `first_name` call if current_user is not nil
@last_name = current_user&.last_name
@account = Account.find_by(uuid: user_profile_params[:account_uuid])
if @user.present?
# We want to filter our selection options for the Organization field by the user's organizations if they are logged in
@organization_options = @user.organizations.map { |org| [org.id, org.name] }
else
# If the user is not logged in, we will show all the organizations
@organization_options = Organization.all.map { |org| [org.id, org.name] }
end
def user_profile_params
params.require(:user_profile_form).permit(
:user_id,
:email,
:first_name,
:last_name,
:organization,
:account_uuid
)
end
end
The User Profile Form View
# /app/views/user_profiles/new.html.haml
%h1 Subscription - User Details
= simple_form_for(@user_profile, url: user_profiles_path) do |f|
= f.error_notification
= f.input :email, label: false, input_html: { value: @email, hidden: true }
= f.input :account_uuid, label: false, input_html: { value: @account&.uuid, hidden: true }
= f.input :first_name, placeholder: 'First name', input_html: { value: @first_name, readonly: @first_name.present? ? true : false }
= f.input :last_name, placeholder: 'Last name', input_html: { value: @last_name, readonly: @last_name.present? ? true : false }
= f.select :organization, options_for_select(@organization)
= f.button :submit, "Submit"
Routing
Last, but not least, we need to add our routes:
Rails.application.routes.draw do
resources :user_accounts, only: %i[new create]
resources :user_profiles, only: %i[new create]
end
Previous post: New Year, New (The)me
Next post: Decorator Pattern in Ruby on Rails