Wicked::Pipeline

RSpec Standard

A step by step pipeline system built on top of Wicked.

Table of Contents

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add wicked-pipeline

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install wicked-pipeline

Compatibility

Currently this gem requires Rails >= 6.1

Usage

You can generate a pipeline using the dedicated generator:

$ rails generate wicked:pipeline Registration identification profile preferences

This will geneate the RegistrationPipeline, IdentificationStep, ProfileStep, and PreferencesStep classes under app/steps. It will also generate test files under spec/steps if RSpec is installed.

You can also generate individual steps:

$ rails generate wicked:pipeline:step identification email subscribe_to_newsletter:boolean interests:array

This will generate the IdentificationStep with the email, subscribe_to_newsletter and interests attributes. It will also generate a test file under spec/steps if RSpec is installed.

Step objects

A step object is very similar to a form object. It takes a "resource" (the ActiveRecord model) and optional params as arguments and will contain attribute definitions, validations for the step, a list of permitted params and a #save method. The validations are specific to the step but the resource will also be validated before being saved.

Step objects are subclasses of the Wicked::Pipeline::BaseStep class, and they live in the app/steps directory. Here are the rules that must be respected in a step object:

  • The name of a step class must end with Step
  • Attributes must be defined using the ActiveModel::Attributes API (more on that later)
    • Nested attributes must not be defined as attributes in the step object.
  • The class must implement the permitted_params method which returns an array of attributes.
  • If the #save method is overridden, super must be called and its value must be used.
    • The #save method must return a boolean value.

The only method that needs to be implemented is the permitted_params private method, everything else is optional. Here is the most basic step object that can exist:

module Users
  class ProfileStep < ::Wicked::Pipeline::BaseStep
    private

    def permitted_params
      []
    end
  end
end

That's it!

As mentioned before, step objects require a resource:

Users::ProfileStep.new(User.last)

Attributes

The attributes of a step object are defined using the .attribute method. The first argument is the name of the attribute, and the second argument is the type of the attribute. The type argument is optional but it must be specified for boolean attributes.

To define a String attributes as an Array, the second argument must be array: true and the :string type must not be specified.

module Users
  class ProfileStep < ::Wicked::Pipeline::BaseStep
    attribute :email
    attribute :first_name
    attribute :last_name
    attribute :is_us_citizen, :boolean
    attribute :investment_goals, array: true

    private

    def permitted_params
      %i[email first_name last_name is_us_citizen investment_goals]
    end
  end
end

The attributes must be in the list of permitted parameters!

Validations

Validations are used the same way as in ActiveRecord models. One exception is the uniqueness validation which is not available in step objects.

Hint: A custom validation method must be used for uniqueness validations, but usually uniqueness validations should be defined in the model.

module Users
  class ProfileStep < ::Wicked::Pipeline::BaseStep
    # ...

    validates_presence_of :email, :first_name, :last_name
    validates :is_us_citizen, inclusion: { in: [true, false] }
    validate :full_name_must_not_be_too_long

    private

    def full_name_must_not_be_too_long
      unless "#{first_name} #{last_name}".length <= 255
        errors.add(:base, :too_long, count: 255)
      end
    end

    # ...
  end
end

Custom validation errors must be added to the step object not the resource itself, they will be merged into the resource's errors automatically.

Blocking

A blocking step will short-circuit a pipeline. In other words, all step following a blocking step will be inaccessible.

A step can be marked as "blocking" by overriding the blocking? predicate method:

module Users
  class ProfileStep < ::Wicked::Pipeline::BaseStep
    attribute :first_name
    attribute :is_us_citizen, :boolean

    def blocking?
      first_name == "John" || is_us_citizen
    end

    # ...
  end
end

Since the blocking? method is a predicate method, it must return a boolean value.

Blocking reason

To specify a reason why the step is marked as blocking, the blocking_reason method should be overridden:

module Users
  class ProfileStep < ::Wicked::Pipeline::BaseStep
    attribute :first_name
    attribute :is_us_citizen, :boolean

    def blocking?
      first_name == "John" || is_us_citizen
    end

    def blocking_reason
      return nil unless blocking?

      if first_name == "John"
        "Too cool for school"
      elsif is_us_citizen
        "Vive la France"
      end
    end

    # ...
  end
end

Step pipelines

A step pipeline class is a subclass of the Wicked::Pipeline::BasePipeline class and live in app/steps. At the most basic level should contain a list of step classes.

Note: Step pipeline should only be used with step objects!

class UserAccountPipeline < Wicked::Pipeline::BasePipeline
  def steps
    [
      User::ProfileStep,
      User::BankingInfoStep,
      User::ObjectivesStep
    ]
  end
end

The order of the steps will be used in the controller/views, so it's easy to reorder steps at any time.

Steps metadata can be accessed using the pipeline. This includes the name of a step, whether or not it is valid and whether or not it is accessible:

UserAccountPipeline.(User.last)
#=> {
#     profile: { valid: true, accessible: true },
#     banking_info: { valid: false, accessible: true },
#     objectives: { valid: false, accessible: false }
#   }

A step is accessible if the previous step is valid and accessible. The first step is always accessible.

Finally, pipelines are also used to check if all steps are valid for a given resource:

UserAccountPipeline.valid?(User.last)

Steps controllers

A steps controller is a subclass of Wicked::Pipeline::BaseStepsController, it can have any name and can be placed anywhere under the app/controllers directory. Unlike a regular controller, the :id parameter references the current step not the ID of a resource. For this reason, the name of the resource ID parameter must be specified using the #resource_param_name private method.

Steps controllers must implement the following private methods:

  • #resource_param_name: This method must return the name of the resource ID parameter (eg: :user_id).
  • #steps_pipeline: This method must return the pipeline class that will be used in the steps controller (eg: UserAccountPipeline)
  • #find_resource: This method takes care of finding the resource object from the database.
class UsersController < Wicked::Pipeline::BaseStepsController
  # ...

  private

  def resource_param_name
    :user_id
  end

  def steps_pipeline
    UserAccountPipeline
  end

  def find_resource
    User.find(params[resource_param_name])
  end
end

Rules to follow

  • The #find_resource method must not set any instance variables.
  • The #find_resource method must only retrieve the record associated with the resource ID (#includes is allowed), not a collection of records.
  • The #find_resource method must return the record.

Routes

The param option must be specified when defining resource routes:

resources :users, only: [:show, :update], param: :user_id

DO NOT use nested routes with the step routes.

Nested routes

When the controller has an index action, nested routes can be defined in the following way:

resources :users, only: [:show, :update], param: :user_id

resources :users, only: [:index] do
  resources :profiles
end

When the controller doesn't have an index action, nested routes should be defined in the following way instead:

resources :users, only: [:show, :update], param: :user_id

scope path: "users/:user_id" do
  resources :profiles
end

Actions

A step controller has the show and update actions. It cannot have the new, edit and create actions! The index and destroy actions can be implemented but they will be independent of the pipeline.

Both action must call super with a block and set the @step_processor instance variable inside of that block:

class UsersController < Wicked::Pipeline::BaseStepsController
  def show
    super do
      @user = find_resource
      @step_processor = step_class.new(@user)
    end
  end

  def update
    super do
      @user = find_resource
      @step_processor = step_class.new(@user, params.require(:user))
    end
  end

  # ...
end

Notes:

  • The step_class method returns the step object class associated with the current step.
  • Other instance variables can be set before calling super or inside the block.

Rules to follow:

  • DO NOT implement the new, edit or create actions.
  • Always call super with a block in the show and update actions.
  • Always set the @step_processor instance variable inside the super block.
  • DO NOT call #save manually, this will be done automatically.
  • DO NOT set the the following instance variable:
    • @step
    • @next_step
    • @previous_step

Flash messages

Flash messages can be set manually after calling super. Step objects have a #saved? method which can be used to verify that it was successfully saved. The method should be used before setting a flash messages:

class UsersController < Wicked::Pipeline::BaseStepsController
  # ...

  def update
    super do
      @user = find_resource
      @step_processor = step_class.new(@user, params.require(:user))
    end

    if @step_processor.saved?
      flash[:success] = t(".success")
    end
  end

  # ...
end

Views

There must be a view for each step, not a partial, it must have the name of the step and it should live in the root directory of the controller's view directory:

app
└── views/
    └── users/
        ├── banking_info.html.erb
        ├── objectives.html.erb
        └── profile.html.erb

Rules to follow

  • DO NOT create a view for the show action.
  • DO NOT create views named new or edit.

Forms

The form in step view must be declared in the following way:

<%= form_for @user, url: step_path, method: :patch do |f| %>
  <% # ... %>
<% end %>

Notes

  • The step_path method refers to the path of the current step. It can also be used to get the path of a specific step:
    step_path(step_name: "objectives")

Rules to follow

  • The resource object must be passed to form_for, not the step processor
  • The form's method must be either :put or :patch

Step controllers provide a extended version of their pipeline's steps metadata with the following added info:

  • :url: The URL of a step
  • :active: Whether or not a step is currently active

This information is made to be used to build breadcrumbs. Here is a basic way to use steps metadata to build breadcrumbs:

<nav aria-label="breadcrumb" class="p-0">
  <ol class="breadcrumb p-0">
    <% steps_metadata.each do |step_name, step_metadata| %>
      <li class="<%= "active-step" if step_metadata[:active] %> <%= "user-#{step_name.dasherize}-step" %> py-2 px-4">
        <% if step_metadata[:accessible] %>
          <%= link_to step_metadata[:url] do %>
            <i class="far <%= step_metadata[:valid] ? "fa-check-square" : "fa-square" %> mr-1"></i>
            <%== t ".#{step_name}" %>
          <% end %>
        <% else %>
          <span>
            <i class="far fa-square mr-1"></i>
            <%== t ".#{step_name}" %>
          </span>
        <% end %>
      </li>
    <% end %>
  </ol>
</nav>

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.

To install this gem onto your local machine, run bundle exec rake install.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/RobertAudi/wicked-pipeline.

The code is linted using Standard. To lint the code run bundle exec rake standard or bundle exec rake standard:fix to autocorrect offenses.

License

The gem is available as open source under the terms of the MIT License.