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.