April 15, 2019; in Ruby on Rails

Trivial Batch Actions in Ruby on Rails

Sometimes, you want to send a message to a bunch of objects. In this case, I want to mark a bunch of a customer’s “Location” records as “do not display”. But of course, sometimes the customer will want to have “do display”.

Update

This is now ActiveAction.

Planning the design

I want to re-use as many Ruby on Rails components, conventions, and ideas as possible. Here’s my plan:

  • write a macro to store my batch actions so I can display them on a button
  • use simple_form
  • use as little javascript as possible

Writing the macro

I’m going to defer to using require_dependency because this code isn’t shared amongst other controllers and it helps keep things clean. I talked about how require_dependency works in a separate blog post.

In app/controllers/application_controller/batch_actions_dependency.rb:

class ApplicationController
  def self.batch_action(action_name, options = {})
    add_batch_action(action_name, options)
  end
  
  def self.batch_actions
    @_batch_actions ||= []
  end

  # define it on the instance so we can access it from our views
  helper_method :batch_actions
  def batch_actions
    self.class._batch_actions
  end

  private 
  def self.add_batch_action(action_name, options = {})
    options[:label] ||= action_name.to_s.titleize
    if action_name.is_a?(Symbol)
      action = OpenStruct.new(action: action_name, label: options[:label])
    elsif action_name.is_a?(Hash)
      action = OpenStruct.new(action: action_name, label: options[:label])
    end
    self.batch_actions << action
  end
end

And then drop it in my application_controller.rb:

require_dependenncy 'application_controller/batch_actions_dependency'

class ApplicationController < ActionController::Base; end

Integrating our DSL into the resource controller

In my controllers, I’m going to use separate methods for each type of batch action. This really does belong in its own controller but for this blog post we’ll be less mindful.

class CustomersController < ApplicationController
  before_action :find_customer, only: [:show, :mark_as_displayable, :mark_as_not_displayable]

  def show
    @customer = @customer.includes(:locations)
  end

  batch_action :mark_as_displayable, label: "Display selected"
  def mark_as_displayable
    @customer.locations.where(id: customer_params[:location_ids]).update_all(display: true)
    # ...
  end

  batch_action :mark_as_not_displayable, label: "Don't display selected"
  def mark_as_not_displayable
    @customer.locations.where(id: customer_params[:location_ids]).update_all(display: false)
    # ...
  end

  private

  def find_customer
    @customer = Customer.find(params[:id])
  end

  def customer_params
    params.require(:customer).permit(location_ids: [])
  end
end

But, of course, brownie points if you extract the relevant methods into BatchLocationsController. Again, for the purposes of this blog post we’ll keep things simple.

Adding the routes

And routes.rb:

resources :customers do
  member do
    patch :mark_as_not_displayable
    patch :mark_as_displayable
  end
end

Writing our view

I’m use SimpleForm because it’s great. I’ll omit the usual submit button and instead submit the form with Javascript, only after altering the action the form submits to. I’ll include checkboxes in each table row:

<%= simple_form_for @customer do |f| %>
  <div class="card">
    <div class="card-header bg-light header-elements-inline">
      Locations
      <div class="header-elements">
        <div class="btn-group">
          <button type="button" class="batch_actions btn btn-outline dropdown-toggle" data-toggle="dropdown" disabled="disabled">Batch actions</button>
          <div class="dropdown-menu dropdown-menu-right">
            <% batch_actions.each do |act| %>
                <a href="#" class="dropdown-item batch_action" data-action="<%= act.action %>"><%= act.label %></a>
            <% end %>
          </div>
        </div>
      </div>
    </div>
    <table class="table table-xs">
       <thead>
       <tr>
         <th></th>
         <th>Name</th>
         <th>Display?</th>
       </tr>
       </thead>
       <tbody>
       <% @customer.locations.each do |location| %>
         <tr>
           <td><input type="checkbox" class="customer_location_ids collection_selection" name="customer[location_ids][]" value="<%= location.id %>" /></td>
           <th><%= location.name %></th>
           <th><%= location.display? %></th>
         </tr>
       <% end %>
       </tbody>
     </table>
  </div>
<% end %>

This input checkbox could be a form builder method but because this is a blog post we’ll be explicit.

Javascript

$('.collection_selection').on('change', function() {
    if($(this).is(':checked')) {
        $(this).closest('tr').addClass('selected');
    } else {
        $(this).closest('tr').removeClass('selected');
    }
    if($('.collection_selection:checked').length > 0) {
        $('.batch_actions').removeAttr('disabled');
    } else {
        $('.batch_actions').attr('disabled', 'disabled');
    }
});
$('.batch_action').on('click', function() {
    var form = $(this).closest('form');
    form.attr('action', form.attr('action') + "/" + $(this).attr('data-action'));
    form.submit();
});

The idea here is to simply change what path the form posts to before submitting it. From there, we can handle the form like we would otherwise.

Obviously, this applies to an association, but the same idea applies for first-level objects as well. You’d want to change members do ... in the routes to collection do ... and the rest should take care of itself.