The magic of class methods for a Ruby on Rails controller and their configurations

Have you used gems that provide class methods for Ruby on Rails controllers that extend these controllers?

Like the ones below:

class TutorialsController < ApplicationController
  
  # This is from cancancan
  # It allows to loading and authorizing
  load_and_authorize_resource
  ...

  # This is from 'has_scope'. 
  has_scope :pref_order, only: :index, default: "suggested"
  
end

Today’s article is about how to implement such a method for our own concern.

My specific case is duplication. I would like to develop a DuplicateResourceConcern that extends the controller and allows users to duplicate a record when they create a new record. This is something we’ve been using at FLLCasts and BuildIn3D for many years, but today I revised the implementation, improved it and decided to share the knowledge in a short article.

The goal is to be able to do:

class TutorialsController < ApplicationController
  
  include DuplicateResourceConcern
  enable_duplicate
  ...  
end

With this logic when the user:

  1. visits /tutorials/new?resource_to_dup=123
  2. The title field in the form for new tutorial is pre-filled the title of Tutorial 123.

Declare DuplicateResourceConcern and enable_duplicate

First we declare DuplicateResourceConcern

module DuplicateResourceConcern
  extend ActiveSupport::Concern

  included do
    before_action :dup, only: [:new]
  end

  private
  def dup
    # call the duplication
  end

end

It is a concern with one method that is ‘dup’

We use the concern in the TutorialsController

class TutorialsController < ApplicationController
  
  include DuplicateResourceConcern
  
  def new
  end  
end

The method ‘dup’ is a private method of the controller. It is called before the :new action. If a param called ‘resource_to_dup=X’ is passed we would like to duplicate the title of Tutorial X.

Simple Tutorial duplication

module DuplicateResourceConcern
  extend ActiveSupport::Concern

  included do
    before_action :dup, only: [:new]
  end

  private
  def dup
    original_tutorial = Tutorial.find_by_id(params.permit(:resource_to_dup)[:resource_to_dup])
    @tutorial = Tutorial.new(title: original_tutorial.title)
  end

end

This duplication will work for the Tutorials controller. But it will not work for ArticlesController, because we are doing “Tutorial.find” and we are assigning a @tutorial variable.

Reusing the DuplicateResourceConcern in a different controller

To reuse the DuplicateResoureConcern we must call Article.find_by_id when we are in an ArticlesController and Tutorial.find_by_id when we are in a TutorialsController.

Here is how we could do this using ‘controller_name’, ‘.classify’ and ‘.constantize’. We can get the name of the records from the controller name.

module DuplicateResourceConcern
  extend ActiveSupport::Concern

  included do
    before_action :dup, only: [:new]
  end

  private
  def dup
    # This will return Tutorial for TutorialsController
    # and will return Article for ArticlesController
    clazz = controller_name.classify.constantize

    # If the controller is called 'tutorials' the instance name will be 'tutorial'
    instance_name = controller_name.singularize

    original_instance = clazz.find_by_id(params.permit(:resource_to_dup)[:resource_to_dup])
    duplicated_instance = clazz.new(title: original_instance.title)

    # After this call we will be able to access @tutorial in the TutorialsController and @article in the ArticlesController
    instance_variable_set("@#{instance_name}", duplicated_instance)
  end

end

In this way the DuplicateResourceConcern is not dependent on the type of the object. It could work for Articles and for Tutorials. It is still dependent on the property ‘title’, but I will leave this for now.

How to configure the duplication

The name of the parameter in the url should be ‘resource_to_dup’. What if we want to modify this name on the controller level. For example to have a parameter
resource_to_dup‘ for TutorialsController and
resource_to_copy‘ for ArticlesController.

What we want to do is pass an argument with the name of the parameter, but we would have to do a little more work.

class TutorialsController < ApplicationController
  
  include DuplicateResourceConcern
  enable_duplicate param_name: "my_new_param_name"
  
  def new
  end  
end

We would use the class_method for the concerns.

module DuplicateResourceConcern
  extend ActiveSupport::Concern

  included do
    before_action :dup, only: [:new]
  end

  def self.included(base)
    base.class_eval do
      extend ClassMethods
      # Declare a class-level attribute whose value is inheritable by subclasses. Subclasses can change their own value and it will not impact parent class.
      # In this way we can extend the class and still have separate configurations
      class_attribute :duplicate_resource_configuration, instance_writer: false
  
      self.duplicate_resource_configuration = {
        options: {
          param_name: "resource_to_dup",
        }
      }
    end
  end

  class_methods do 
    # The enable_duplicate method will be called on a class level.
    def enable_duplicate(*args) do
      options = args.extract_options!
      options.symbolize_keys!

      # We check that there are only parameters with this names.
      options.assert_valid_keys(:param_name)

      # Merge the default options with the options passed in the controller
      self.duplicate_resource_configuration[:options].merge!(options)

      # We mark that we've enabled the duplication
      self.duplicate_resource_configuration[:enabled] = true
    end

  end


  private
  def dup
    # This will return Tutorial for TutorialsController
    # and will return Article for ArticlesController
    clazz = controller_name.classify.constantize

    # If the controller is called 'tutorials' the instance name will be 'tutorial'
    instance_name = controller_name.singularize

    # Param name. We are no longer dependent on 
    # the param name being resource_to_dup. It could 
    # be a different name.
    the_param_name = self.duplicate_resource_configuration[:options][:param_name]

    original_instance = clazz.find_by_id(params.permit(the_param_name)[the_param_name])
    duplicated_instance = clazz.new(title: original_instance.title)

    # After this call we will be able to access @tutorial in the TutorialsController and @article in the ArticlesController
    instance_variable_set("@#{instance_name}", duplicated_instance)
  end

end

Extensibility

This is the basic structure. We can now add more options if we want to and more parameters to the configurations.

What is the system RSpec for duplication.

It is simple.

scenario "/tutorials/new?resource_to_dup can duplicate a resource" do
    tutorial.update(title: SecureRandom.hex(10))

    visit "/tutorials/new?resource_to_dup=#{tutorial.id}"
    expect(page).to have_text "Duplicate from #{tutorial.title}"

    click_on "Create Tutorial"
    expect(page).to have_text "Tutorial was successfully created."

    tutorials = Tutorial.where(title: tutorial.title)
    expect(tutorials.count).to eq 2
  end