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
- Planning the design
- Writing the macro
- Integrating our DSL into the resource controller
- Adding the routes
- Writing our view
- Javascript
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.