17 Oct 2013

Building Objects in the Session with Rails and Wicked Wizard

Recently I stumbled across a great gem, Wicked Wizard. As I dug in further I noticed they had a great guide for building partial objects step by step. However, it didn't seem to do quite what I wanted. I was looking for something that:

  1. Doesn't require the model have knowledge of the wizard in order to perform validations.
  2. Doesn't require me to clean up incomplete objects when someone didn't go through the whole wizard.

I decided to start tackling requirement 2 first, since I had an idea how to go about that. I figured if I stored objects in the session, I could just keep building it there until it came time for the final submission. I googled for a solution and the closest thing I could find was this gist about using the session to store a partial object. When I tried to use it though, it didn't work for me. Based on the gist though, I was able to come up with a solution which I'll detail below. I'll use the same example as the Wicked Wizard guide (a product).

First, let's create our product class:

1 class Product < ActiveRecord::Base
2   attr_accessible :category, :name, :price
3 
4   validates_presence_of :category, :name, :price
5 end

Next, let's begin creating our controller. Starting from what Wicked Wizard tells us to do, we'd have something like this:

 1 class ProductWizardController < ApplicationController
 2   include Wicked::Wizard
 3 
 4   steps :add_name, :add_price, :add_category
 5 
 6   def show
 7     @product = Product.find(params[:product_id])
 8     render_wizard
 9   end
10 
11   def update
12     @product = Product.find(params[:product_id])
13     @product.update_attributes(params[:product])
14     render_wizard @product
15   end
16 
17   def create
18     @product = Product.create
19     redirect_to wizard_path(steps.first, :product_id => @product.id)
20   end
21 end

But the Product.find(params[:product_id]) and Product.create lines aren't going to work for us anymore. In fact, it doesn't make sense to have a create action at all, since we don't create a product to start. So let's remove the create action and put a new action in it's place:

1 def new
2   product = WizardProduct.new
3 
4   session[:product_wizard] ||= {}
5   session[:product_wizard][:product] = product.accessible_attributes
6 
7   redirect_to wizard_path(steps.first)
8 end

You haven't seen the WizardProduct class yet, but we'll get to that in a minute. What we've done is instantiated a new product, stored any data that product has in the session, and then redirected to step one of our wizard, :add_name. As I mentioned earlier, we can't use Product.find anymore, so let's change our show action to the following:

1 def show
2   @product = WizardProduct.new(session[:product_wizard][:product])
3   @step = step
4 
5   render_wizard
6 end

Instead of going and finding our object like we would with a database, we're going to instantiate a new one each time with the attributes we've stored in the session. Initially I tried storing the whole object in the session but due to limitations on how much you can store there this would consistently fail.

Next, we'll also need to remove Product.find in the update action:

 1 def update
 2   @product = WizardProduct.new(session[:product_wizard][:product])
 3   @product.attributes = params[:product]
 4 
 5   @product.step = step
 6   @product.steps = steps
 7   @product.session = session
 8   @product.validations = validations
 9 
10   render_wizard @product
11 end

Now things are getting a bit more complicated and it's time to discuss the WizardProduct class. What we've done so far is to instantiate our WizardProduct object with the attributes stored in the session, and we've added a bunch of data to it that our WizardProduct class will need to do it's job. Let's take a look at WizardProduct:

 1 class WizardProduct < Product
 2   attr_accessor :step, :steps, :session, :validations
 3   
 4   @@parent = superclass
 5   
 6   def underscored_name
 7     @@parent.name.underscore
 8   end
 9   
10   def self.model_name
11     @@parent.model_name
12   end
13   
14   def save
15     valid?
16     
17     remove_errors_from_other_steps
18     
19     if errors.empty?
20       if step == steps.last
21         obj = @@parent.new(accessible_attributes)
22         
23         session.delete("#{underscored_name}_builder".to_sym) if obj.save
24       else
25         session["#{underscored_name}_builder".to_sym][underscored_name.to_sym] = accessible_attributes
26       end
27     end
28   end
29   
30   def accessible_attributes
31     aa = @@parent.accessible_attributes
32     attributes.reject! { |key| !aa.member?(key) }
33   end
34   
35   def remove_errors_from_other_steps
36     other_step_validation_keys = (errors.messages.keys - validations[step])
37     errors.messages.reject! { |key| other_step_validation_keys.include?(key) }
38   end
39 end

OK. What does this do? Well we've got some accessors for the step we're currently on, a list of all the steps, the session itself, and validations we need to perform. Then we store the parent class as a class variable, and get some helper methods related to the parent class that we'll need later.

A key part of this is rewriting the save method. Here, we check if the object is valid, and then remove all the errors we don't care about. Finally, we check if there are no errors. If not, but we're not at the last step yet, we store the attributes of the object in the session. The controller takes care of moving to the next step. If we're at the last step, we instantiate a new instance of the parent model with the attributes we retrieved from the session, and save it. And there you have it!

Limitations

There are a couple limitations with the way I'm currently doing things.

  1. All attributes must be mass-assignable. This is how I can do @@parent.new(accessible_attributes). There are ways around this, but it makes sense, since you are assigning these attributes through the interface. I'm working on an updated version for Rails 4 that will use strong params instead.
  2. Assocations don't work at all. If you accept nested attributes for things, this just flat out won't work. It is fairly easy to make it work on a case by case basis, but I haven't figured out a generalized solution yet. I'm happy to hear suggestions!

If you have ways you think these limitations could be overcome, or you think I could do certain things better, feel free to get in touch with me on Twitter.

Help

Please help! Accepting pull requests, praise, criticism, and thinly veiled hatred.

Final notes

You can find the project on GitHub at github.com/ericroberts/wizard-object

By the way, here's the full controller with all the changes we made:

 1 class ProductWizardController < ApplicationController
 2   include Wicked::Wizard
 3 
 4   steps :add_name, :add_price, :add_category
 5 
 6   def new
 7     product = WizardProduct.new
 8 
 9     session[:product_wizard] ||= {}
10     session[:product_wizard][:product] = product.accessible_attributes
11 
12     redirect_to wizard_path(steps.first)
13   end
14 
15   def show
16     @pick = WizardProduct.new(session[:product_wizard][:product])
17     @step = step
18 
19     render_wizard
20   end
21 
22   def update
23     @product = WizardProduct.new(session[:product_wizard][:product])
24     @product.attributes = params[:product]
25 
26     @product.step = step
27     @product.steps = steps
28     @product.session = session
29     @product.validations = validations
30 
31     render_wizard @product
32   end
33 
34   def finish_wizard_path
35     products_path
36   end
37 
38   def validations
39     {
40       add_name: [:name],
41       add_price: [:price],
42       add_category: [:category]
43     }
44   end
45 end
comments powered by Disqus