Rails Wizards Pt. 4

Jon Sully

5 Minutes

Model Validations

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, ruby>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: ruby>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 (ruby>:address_info, ruby>:house_info, and ruby>:house_stats)
  • (2) orders them top-to-bottom, hinting at (but not enforcing) the standard ‘happy path’ direction for filling out steps (ruby>:address_info, then ruby>:house_info, then ruby>: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 ruby>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 ruby>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!

{outputLines: 2-13,15-16,19-20,22-23,25-26,28,30-31,33-34,37-38,40-41,43-44,47-48,50-51,53}{promptUser: “}{promptHost:  pry}

irb:> 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>

irb:> h.valid?
=> true

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

irb:> h.valid?
=> false

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

# Great!

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

irb:> h.valid?
=> true

# Validate for a different step; should fail!

irb:> h.form_step = :house_info
=> :house_info

irb:> h.valid?
=> false

irb:> 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

irb:> h.form_step = nil
=> nil

irb:> h.valid?
=> false

irb:> 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

Comments? Thoughts?

Saverio Trioni

Another option here could be using validates ..., on: [:create, :update, *list_of_steps] and use a different context when saving from each step record.save(context: :my_step)

Jon Sully (Author)

I did play around with context-based validations quite a bit but ran into a number of roadblocks where the validations would only be applied for a given context and not for an object which did not have a current form_step. To me, at the time, it felt more important to have validations that worked seamlessly within the context of a wizard step and when there was no step present than trying to coordinate the context 😕

Reply

Please note: spam comments happen a lot. All submitted comments are run through OpenAI to detect and block spam.