Rails Wizards Pt. 7

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!

Modifications & Options

I just want to cover a few ideas here — more from a point of creative possibility than pedagogy. Think of these more like ‘extra bits’ you can choose to add to your wizard setups if you’d like to but which are completely unnecessary.

Default Scopes

(For database-persistence wizards)

I know, I know, default scopes are scary, bad, mean things that should never exist and always bring pain. That’s not always the case but I understand the belief and I know it’s typically correct. But incomplete wizard objects littering our database tables when using database-persistence can be severely annoying. As long as we don’t actually need to know the mid-wizard-progress of a given object, a default scope can actually be handy.

I experimented with adding an additional column to my model specifically to capture the “wizard done-ness” but again, these are just idea seeds for you to play with. Ignoring the null: false, default: false I manually added in the migration (since they can’t be added via command line), this was a simple generation:

rails g migration AddWizardCompleteToHouse wizard_complete:boolean

Followed by adding the scope(s) to the House model itself:

  class House < ApplicationRecord
+   default_scope { where wizard_complete: true }
+   scope :wizard_not_completed_only, -> { unscope(where: :wizard_complete).where(wizard_complete: false) }
    
    enum form_steps: {
    # ...
  end

The benefit here is that any time I call for a specific House.last or run an ActiveRecord query against House, I can be sure that only completed Houses will be included in the results. Additionally, since default scopes impact the new record defaults, this will also ensure that anywhere else in my application that I create a new House (say, an admin dashboard or back-end process that doesn’t require the nice UX of a wizard) that House record will automatically have wizard_complete: true.

If you want to go ahead with this route, you will need to change your controllers a little bit. Your top-level controller will need to explicitly specify that it’s creating a new record with wizard_complete: false:

  # app/controllers/houses_controller.rb
  class HousesController < ApplicationController
    # ...
    # GET /houses/new
    def new
-     @house = House.new
+     @house = House.new wizard_complete: false
      @house.save! validate: false
      redirect_to house_step_path(@house, House.form_steps.keys.first)
    end
    # ...
  end

and your steps controller will need to access the ‘other side of the scope’ so that it can find the record that gets created in the top-level controller. Finally, we’ll use the finish_wizard_path method as the perfect place to update the object as being complete:

  # app/controllers/steps_controllers/house_steps_controller.rb
  module StepsControllers
    class HouseStepsController < ApplicationController
      include Wicked::Wizard

      steps *House.form_steps.keys

      def show
-       @house = House.find(params[:house_id])
+       @house = House.wizard_not_completed_only.find(params[:house_id])
        render_wizard
      end

      def update
-       @house = House.find(params[:house_id])
+       @house = House.wizard_not_completed_only.find(params[:house_id])
        # Use #assign_attributes since render_wizard runs a #save for us
        @house.assign_attributes house_params
        render_wizard @house
        # or #valid? check if using Turbo
      end

      private

      # Only allow the params for specific attributes allowed in this step
      def house_params
        params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
      end

      def finish_wizard_path
+       @house.update! wizard_complete: true
        house_path(@house)
      end
    end
  end

Voilà! Default scoped wizard! It’s an idea anyway. Give it a try and see what you think and if it works for you 😀

Avoiding Nosy Neighbors

(For database-persistence wizards with ID-in-URL routing and no authentication)

When running public-facing database-persisted wizards with the ID-in-URL routing strategy, you end up with URLs like:

/houses/14/steps/address_info

and, without any authentication to tie the record to, it becomes apparent to even the most basic of sneaky users that they may be able to switch the URL in their address bar to:

/houses/28/steps/address_info

and see what data another anonymous user may have filled out. That isn’t the case with ID/Key-in-Session wizard approaches nor with the Key-in-URL approach using cache-persistence (since the cache key is randomized), but it’s hard to avoid with the database + in-URL approach. The two main workarounds are: (1) don’t use an integer primary key as the route key, and (2) keep the record ID in the user’s session. Since the latter is just a full switch to the database-persistence + ID-in-session approach, that really leaves us with just: don’t use an integer primary key as the route key. Plenty of Rails apps over the last decade have leveraged the friendly_id gem to avoid the integer-primary-key trap, but I’ll leave the overall implementation as an exercise to the reader. Ideally we’d generate a new ‘slug’ ID in the top-level controller’s new method, save it, and redirect to the step-controller using that ‘slug’ ID in the path helper.

Determining Current Step by Validations

There are a few neat and handy tricks available to us given our model validations setup and having broken each step’s validating-attributes into their own groups. For instance, we can add an instance method to our model that will effectively tell us the step that instance should be on — the first step that the validations aren’t passing:

  # app/models/house.rb
  class House < ApplicationRecord
    # ...
+   def current_step
+     @current_step ||= self.class.form_steps.keys.map(&:to_sym).find do |step|
+       self.form_step = step
+       step unless valid?
+     end
+   end
  end

The nice part here is that the current_step should always be a symbol that we can pass back to Wicked and force the user’s next wizard step, even if it’s not in linear order.

We can actually use this pattern (set self.form_step to any of the self.class.form_steps.keys to determine if that step has been satisfied) to drive non-linear forms or forms with multiple entry-points. For the most part I’ll leave that as another exercise for your imagination and creativity, but don’t be afraid to experiment with this framework and see how smoothly you can wire up non-linear actions.

Onward!

Okay, enough with the code. Let’s figure out if we ‘did the thing’ successfully. → Part 8: Did We Do the Thing?

Header Photo / Banner by: @kellysikkema

Join the Conversation

Latest Blog Posts