Trivial Batch Actions

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”.

I want to re-use as many Rails components 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.

First, the macro:

I like using require_dependency because it keeps things clean. I’m dropping the following in app/controllers/application_controller/batch_actions.rb:

class ApplicationController
  # DSL 
  def self.batch_action(action_name, options = {})
    add_batch_action(action_name, options)
  end
 
  # actually add the action to the collection of actions. 
  # if @_batch_actions is nil, let's create it as an array 
  def self.add_batch_action(action_name, options = {})
    @_batch_actions ||= []
    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
    @_batch_actions << action 
  end

  # reader 
  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
end

And then drop it in my application_controller.rb:

class ApplicationController < ActionController::Base
  require_dependenncy 'application_controller/batch_actions'
end 

In my controllers, I’m going to use separate methods for each type of batch action. This is ugly to look at if you’re trying your hardest to adhere to RESTful routes, but I don’t care.

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

And routes.rb:

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

I’m going to use SimpleForm because it’s great. I’ll omit the usual submit button and instead submit the form with javacript, 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 %>

And some awkward, first-pass 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.