Rails Wizards Pt. 4
Jon Sully
5 Minutes
Model Validations
-
Part of the
‘Rails Wizards’
Series:
- • Apr 2021: Rails Wizards Pt. 1
- • Apr 2021: Rails Wizards Pt. 2
- • Apr 2021: Rails Wizards Pt. 3
- • Apr 2021: Rails Wizards Pt. 4 (This page)
- • Apr 2021: Rails Wizards Pt. 5
- • Apr 2021: Rails Wizards Pt. 6
- • Apr 2021: Rails Wizards Pt. 7
- • Apr 2021: Rails Wizards Pt. 8
- • Apr 2021: Rails Wizards Pt. 9
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:
- Users should tell me the address info (
address
andcurrent_family_last_name
) so I can… know who to send the mail to? - Give me info about the house colors (
interior_color
andexterior_color
) - Describe the inside (
rooms
andsquare_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
, andruby>: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
, thenruby>:house_info
, thenruby>: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 😕