Rails Wizards Pt. 4

Series Breakdown
  1. Part 1: What’s a Wizard and Why are We Here?
  2. Part 2: Choose Your Data Persistence
  3. Part 3: Choose Your URL Strategy
  4. Part 4: Model Validations
  5. Part 5: Routing and Controllers
  6. Part 6: Add Hotwire / Turbo!
  7. Part 7: Other Modifications / Options
  8. Part 8: Did We Do the Thing?
  9. Part 9: See it in Action!

Model Validations

So we’ve got a plan for how we’re going to persist the user-submitted data across each wizard step and we’ve got a routing plan in place for how to setup our URLs and bind our steps to the data-persistence object — let’s get into some code!

One of my biggest hopes in a wizard is to have a single Rails Model that encapsulates all of the validations for the model but which can selectively apply them too. This is mostly so that I can have a RESTful JSON API endpoint where all of the object data comes in at once for a POST / #create and all of the object validations run, but the same model can work behind a wizard where each step in the wizard only necessitates a subset of the model’s validations. The goal is to have each step validated correctly against its subset of fields while still maintaining the capability of validating the whole object as needed.

For the rest of this series we’re going to use House as our example model / scaffold, but feel free to check out Part 9 where I’ve built every variation of wizard described in this series using various models. Moving forward, here’s the House scaffold:

$> rails g scaffold House address exterior_color interior_color current_family_last_name rooms:integer square_feet:integer

Our hypothetical wizard workflow for this object looks something like this. Each “wizard step” is described as:

  1. Users should tell me the address info (address and current_family_last_name) so I can… know who to send the mail to?
  2. Give me info about the house colors (interior_color and exterior_color)
  3. Describe the inside (rooms and square_feet)

So three steps to talk about the House. Maybe this is a wizard for a realtor’s website. Who knows 😛

Given that workflow, we’re going to start by adding an enum and an attr_accessor to the model, House. It looks like this:

# app/models/house.rb
class House < ApplicationRecord
  enum form_steps: {
    address_info: [:address, :current_family_last_name],
    house_info: [:interior_color, :exterior_color],
    house_stats: [:rooms, :square_feet]
  }
  attr_accessor :form_step
end

NOTE: This isn’t so much an enum for the sake of encoding an id-to-symbol lookup into the class definition (as you’d typically use them) but moreso a simplistic way to define a hash on the class object with a very clean accessor: House.form_steps which will come in handy. This style of enum definition doesn’t relate to the database and there is no form_step_id column on our model’s table. It’s just a hash constant.

In terms of what this enum is doing, it:

  • (1) names each specific step involved in building up this model incrementally (:address_info, :house_info, and :house_stats)
  • (2) orders them top-to-bottom, hinting at (but not enforcing) the standard ‘happy path’ direction for filling out steps (:address_info, then :house_info, then :house_stats)
  • (3) defines exactly which attributes on the model are intended to be contained by and validated as part of any given step.

That’s a lot of functionality in a single hash! The attr_accessor is intentionally not a persisted field in the database, but thanks to a fun state-management trick it will tell us which step any given House needs to validate for. More on that later.

Now the actual validations. We’re going to start by adding this method to our model. Feel free to pull this out into a concern if you’re working with multiple wizards in your code.

def required_for_step?(step)
  # All fields are required if no form step is present
  return true if form_step.nil?

  # All fields from previous steps are required
  ordered_keys = self.class.form_steps.keys.map(&:to_sym)
  !!(ordered_keys.index(step) <= ordered_keys.index(form_step))
end

required_for_step? is a mechanism for determining if a particular validation is necessary based on the current object’s form_step (the attr_accessor above). It makes more sense in context. Here’s the full model:

  # app/models/house.rb
  class House < ApplicationRecord
    enum form_steps: {
      address_info: [:address, :current_family_last_name],
      house_info: [:interior_color, :exterior_color],
      house_stats: [:rooms, :square_feet]
    }
    attr_accessor :form_step

+   with_options if: -> { required_for_step?(:address_info) } do
+     validates :address, presence: true, length: { minimum: 10, maximum: 50}
+     validates :current_family_last_name, presence: true, length: { minimum: 2, maximum: 30}
+   end
+
+   with_options if: -> { required_for_step?(:house_info) } do
+     validates :interior_color, presence: true
+     validates :exterior_color, presence: true
+   end
+
+   with_options if: -> { required_for_step?(:house_stats) } do
+     validates :rooms, presence: true, numericality: { gt: 1 }
+     validates :square_feet, presence: true
+   end

    def required_for_step?(step)
      # All fields are required if no form step is present
      return true if form_step.nil?
    
      # All fields from previous steps are required
      ordered_keys = self.class.form_steps.keys.map(&:to_sym)
      !!(ordered_keys.index(step) <= ordered_keys.index(form_step))
    end
  end

So given a bit more context of how required_for_step? is used, it’s effectively a conditional governance around validations — only enabling the validations for a given form_step (and all those prior) if the object instance is actually on a form_step. If the object isn’t on a form_step at all, all validations will execute normally. This is super important for making sure that our House model works “totally normally” across the rest of our application while running some partial validations during this multi-step form. If there’s a secret sauce to wizards, I think this is it!

Let’s do some quick gut-checks in a Rails console to make sure our validations are working the way we want them to. I’ll create a blank object, add a few attributes, tell it what wizard step to validate for, and see if the validations work!

h = House.new address: '123 somewhere way', current_family_last_name: 'Sully', form_step: :address_info
=> <House:0x00007fda6ad417e0
 id: nil,
 address: "123 somewhere way",
 exterior_color: nil,
 interior_color: nil,
 current_family_last_name: "Sully",
 rooms: nil,
 square_feet: nil,
 created_at: nil,
 updated_at: nil,
 form_steps: nil>

h.valid?
=> true

# Change data to invalid; should fail!
h.address = 'short'
=> "short"

h.valid?
=> false

h.errors.first.full_message
=> "Address is too short (minimum is 10 characters)"

# Great!

h.address = '123 somewhere way'
=> "123 somewhere way"

h.valid?
=> true

# Validate for a different step; should fail!
h.form_step = :house_info
=> :house_info

h.valid?
=> false

h.errors.map &:full_message
=> ["Interior color can't be blank", "Exterior color can't be blank"]

# Validate without a step / as the 'full' object
h.form_step = nil
=> nil

h.valid?
=> false

h.errors.map &:full_message
=> ["Interior color can't be blank", "Exterior color can't be blank", "Rooms can't be blank", "Rooms is not a number", "Square feet can't be blank"]

Awesome! Our step-based validations are working in the intuitive, expected way. With that, let’s move on to the controllers → Part 5

Want to stay in the loop?

1 - 3 emails/month. No spam. No data selling. Never.

Join the Conversation

Latest Blogposts

Friday Guitar Session

Friday Guitar Session

Feat. Mayer vibes

June 4 2021

📸 May 31

📸 May 31

Memorial Day cookout...

May 31 2021

📸 May 31

📸 May 31

Zoo days...

May 31 2021

📸 May 30

📸 May 30

Bonfire nights 🔥

May 30 2021