-
Notifications
You must be signed in to change notification settings - Fork 190
Building Partial Objects Step by Step
This question comes up a lot, people want to have an object, lets call it a Product
that they want to create in several different steps. Let's say our product has a few fields name
, price
, and category
and to have a valid product all these fields must be present.
We want to build an object in several different steps but we can't because that object needs validations. Lets take a look at our Product
model.
class Product < ActiveRecord::Base
validates :name, :price, :category, presence: true
end
So we have a product that relies on name, price, and category to all be there. Lets take a look at a simple Wizard controller we'll make a Products::BuildController. It is located at app/controllers/products/build_controller.rb
class Products::BuildController < ApplicationController
include Wicked::Wizard
steps :add_name, :add_price, :add_category
def show
@product = Product.find(params[:product_id])
render_wizard
end
def update
@product = Product.find(params[:product_id])
@product.update_attributes(params[:product])
render_wizard @product
end
def create
@product = Product.create
redirect_to wizard_path(steps.first, product_id: @product.id)
end
end
Since Wicked uses our :id
parameter we will need to have a route that also includes :product_id
for instance /products/:product_id/build/:id
. This is one way to generate that route:
resources :products do
resources :build, controller: 'products/build'
end
This also means to get to the create action we don't have a product_id
yet so we can either create this object in another controller and redirect to the wizard, or we can use a route with a placeholder product_id
such as [POST] /products/building/build
in order to hit this create action.
We also have another problem, if we've added validations to our product requiring fields that are set later in the wizard, it will fail to store to the database. How can we keep all of our validations, but let this invalid object save to the database?
The best way to build an object incrementally with validations is to save the state of our product in the database and use conditional validation. To do this we're going to add a status
field to our Product
class.
Notice: Another method for partial validations, which might be considered more flexible by some users (allowing for easy validation testing inside model tests), was described by Josh McArthur here.
class ProductStatus < ActiveRecord::Migration
def change
add_column :products, :status, :string
end
end
Now we want to add an active
state to our Product
model.
def active?
status == 'active'
end
And we can add a conditional validation to our model.
class Product < ActiveRecord::Base
validates :name, :price, :category, presence: true, if: :active?
def active?
status == 'active'
end
end
Now we can create our Product
and we won't have any validation errors, when the time comes that we want to release the product into the wild you'll want to remember to change the status of our Product on the last step.
class Products::BuildController < ApplicationController
include Wicked::Wizard
steps :add_name, :add_price, :add_category
def update
@product = Product.find(params[:product_id])
params[:product][:status] = 'active' if step == steps.last
@product.update_attributes(params[:product])
render_wizard @product
end
end
So that works well, but what if we want to disallow a user to go to the next step unless they've properly set the value before it. We'll need to split up our validations to support multiple conditional validations.
class Product < ActiveRecord::Base
validates :name, presence: true, if: :active_or_name?
validates :price, presence: true, if: :active_or_price?
validates :category, presence: true, if: :active_or_category?
def active?
status == 'active'
end
def active_or_name?
status.include?('name') || active?
end
def active_or_price?
status.include?('price') || active?
end
def active_or_category?
status.include?('category') || active?
end
end
Then in our Products::BuildController Wizard we can set the status to the current step name in in our update.
def update
@product = Product.find(params[:product_id])
params[:product][:status] = step.to_s
params[:product][:status] = 'active' if step == steps.last
@product.update_attributes(params[:product])
render_wizard @product
end
So on the :add_name
step status.include?('name')
will be true
and our product will not save if it isn't present. So in the update action of our controller if @product.save
returns false then the render_wizard @product
will direct the user back to the same step :add_name
. We still set our status to active on the last step since we want all of our validations to run.
What you're trying to do is fairly complicated, we're essentially turning our Product model into a state machine, and we're building it inside of our wizard which is a state machine. Yo dawg, i heard you like state machines... This is a very manual process which gives you, the programmer, as much control as you like.
If you have conditional validation it can be easy to have incomplete Products laying around in your database, you should set up a sweeper task using something like Cron, or Heroku's scheduler to clean up Products that are not complete.
lib/tasks/cleanup.rake
namespace :cleanup do
desc "removes stale and inactive products from the database"
task :products => :environment do
# Find all the products older than yesterday, that are not active yet
stale_products = Product.where("DATE(created_at) < DATE(?)", Date.yesterday).where("status is not 'active'")
# delete them
stale_products.map(&:destroy)
end
end
When cleaning up stale data, be very very sure that your query is correct before running the code. You should also be backing up your whole database periodically using a tool such as Heroku's PGBackups incase you accidentally delete incorrect data.
Hope this helps, I'll try to do a screencast on this pattern. It will really help if you've had problems implementing this, to let me know what they were. Also if you have another method of doing partial model validation with a wizard, I'm interested in that too. As always you can find me on the internet @schneems. Thanks for using Wicked!