ruby-on-railsrubywizardformwizard

rendering first step of multistep form wizard as partial in another controller's show action


I want to render the first step of a multistep form for @trade_wizard (which has it's own controller, WizardsController) as a partial inside ItemsController#show, but I don't know how to build this without doubling the code from one controller into the other.

I'm rendering the first step inside the Item's show page:

<%= render "/wizards/step1" %>

@trade_wizard is handled in a special model that instantiates @trade, and then successively inherits validations from each step:

module Wizard
  module Trade
    STEPS = %w(step1 step2 step3).freeze

    class Base
      include ActiveModel::Model
      attr_accessor :trade

      delegate *::Trade.attribute_names.map { |attr| [attr, "#{attr}="] }.flatten, to: :trade

      def initialize(trade_attributes)
        @trade = ::Trade.new(trade_attributes)
      end
    end

    class Step1 < Base
      validates :trade_requester_id, :trade_recipient_id, :wanted_item_id, presence: true
      validates :shares, numericality: { only_integer: true, greater_than_or_equal_to: 0, 
                  less_than_or_equal_to: :max_shares }

      def max_shares
        @trade.wanted_item.shares
      end

    end

    class Step2 < Step1
      validates :collateral_item_id, presence: true
    end

    class Step3 < Step2
      validates :agreement, presence: true
    end
  end
end

And then my WizardsController runs validations on each step and saves the object:

class WizardsController < ApplicationController
  before_action :load_trade_wizard, except: %i(validate_step)

  def validate_step
    current_step = params[:current_step]

    @trade_wizard = wizard_trade_for_step(current_step)
    @trade_wizard.trade.attributes = trade_wizard_params
    session[:trade_attributes] = @trade_wizard.trade.attributes

    if @trade_wizard.valid?
      next_step = wizard_trade_next_step(current_step)
      create and return unless next_step

      redirect_to action: next_step
    else
      render current_step
    end
  end

  def create
    if @trade_wizard.trade.save
      session[:trade_attributes] = nil
      redirect_to root_path, notice: 'Trade succesfully created!'
    else
      redirect_to({ action: Wizard::Trade::STEPS.first }, alert: 'There were a problem when creating the trade.')
    end
  end

  private

  def load_trade_wizard
    @trade_wizard = wizard_trade_for_step(action_name)
  end

  def wizard_trade_next_step(step)
    Wizard::Trade::STEPS[Wizard::Trade::STEPS.index(step) + 1]
  end

  def wizard_trade_for_step(step)
    raise InvalidStep unless step.in?(Wizard::Trade::STEPS)

    "Wizard::Trade::#{step.camelize}".constantize.new(session[:trade_attributes])
  end

  def trade_wizard_params
    params.require(:trade_wizard).permit(:trade_requester_id, :trade_recipient_id, :wanted_item_id, :collateral_item_id, :shares, :agreement)
  end

  class InvalidStep < StandardError; end
end

In my routes I have

resource :wizard do
    get :step1
    get :step2
    get :step3
    post :validate_step
end

The error I get with this setup is First argument in form cannot contain nil or be empty. I know why this happens - I need to define @trade_wizard inside ItemsController#show, which I'm not doing yet, because that just results in me duplicating code from WizardsController. I don't need anyone to do my work for me, I just need a pointer for how I can build my way out of this problem.


Solution

  • Controllers are designed to be independent, they cannot depend on each other. This is different than views, than may be reused and composed through partials, as you are doing.

    If you need to reuse behavior in controllers (which is not the same as one controller depending on another one), you may use inheritance or, following the Rails Way, concerns.

    In this case, I would create a concern to setup the @trade_wizard variable in any controller that includes the wizards/step1partial view.