diff --git a/Gemfile b/Gemfile index 6250fc9..cd93635 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,4 @@ gemspec gem 'rspec' gem 'rake' gem 'simplecov' - +gem 'rr' diff --git a/README.md b/README.md index 22bda4f..236bee9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # BatchActions -This gem adds generic support for batch actions to Rails controllers. +This gem adds generic support for batch actions to Rails controllers and +InheritedResources. [![Travis CI](https://secure.travis-ci.org/grindars/batch_actions.png)](https://travis-ci.org/grindars/batch_actions) [![Code Climate](https://codeclimate.com/github/grindars/batch_actions.png)](https://codeclimate.com/github/grindars/batch_actions) @@ -27,27 +28,78 @@ Or install it yourself as: class PostController < ApplicationController include BatchActions - batch_model Post + batch_actions do + model Post + + # Produces #batch_publish action. Requires params[:ids] to get affected + # instances and call #publish on them. + batch_action :publish + + # params[:batch_destroy] should be an array containing affected ids + batch_action :destroy, param_name: :batch_destroy + + # Produced controller action will be called #mass_unpublish (instead of + # batch_unpublish by default). Method #draft! will be called for each + # affected instance. + batch_action :unpublish, action_name: :mass_unpublish, batch_method: :draft! + + # Affected objects will be got inside #destroyed scope, redirection will + # be done to params[:return_to] instead of url_for(action: :index) + batch_action :restore, + scope: ->(model, ids) { Post.destroyed.where(id: ids) }, + respose: -> { + respond_to do |format| + format.html { redirect_to params[:return_to] } + end + } + + # Produces action #do_batch with dispatches request to concrete + # actions. Concrete action is determined by param named as action name + # ("destroy=true" for batch_action :destroy) or by :trigger option value + # ("call_destroy=true" for batch_action :destroy, trigger: :call_destroy). + dispatch_action(:do_batch) + end +end +``` - # Runs `model.publish` for every model from params[:ids] - batch_action :publish +## Inheritance - # Runs `model.destroy` for every model from params[:ids] or throws exception unless you can - batch_action :destroy, if: ->() { can? :destroy, Post } +Batch action options and batch actions could be inherited. - # Runs block for every model from params[:ids] - batch_action :specific do |objects| - objects.each{|x| x.specific!} +``` +class Admin::BaseController < ApplicationController + include BatchActions + batch_actions do + param_name :ids_eq end +end - # Runs `model.resurrect` for every model from returned relation - batch_action :resurrect, :scope => ->(ids) { Post.where(other_ids: ids) } +class Admin::NewsController < Admin::BaseController + # You can omit #batch_actions call if you do not want to set options. + batch_action :destroy + batch_action :publish end ``` -Note that you can omit `batch_model` call if you use the [inherited_resources](https://github.com/josevalim/inherited_resources) gem. It grabs your model class from `resource_class`. +`#batch_destroy` and `#batch_publish` will require `params[:ids_eq]` to work. + +## CanCan + +Because of every batch_action creates action called `batch_#{name}`, you can +control access rights with CanCan. Action name could be overriden with +`:action_name` param. + +## InheritedResources + +Note that you can omit `model` call if you use the [inherited_resources](https://github.com/josevalim/inherited_resources) gem. It grabs scope from `resource_class` and scope from `end_of_association_chain`. + +## TODO -There's one more important thing to know: set of active batch actions can be retrieved from controller by calling `batch_actions` on controller instance. +1. call before and after filters for producted actions if they are called from + dispatcher. +1. implement flash messages with inherited_resources responders for example. +2. autoinclude it to actioncontroller inside railtie. +3. :tirgger param must be a proc. ## Contributing diff --git a/lib/batch_actions.rb b/lib/batch_actions.rb index 6b48442..b4edbbc 100644 --- a/lib/batch_actions.rb +++ b/lib/batch_actions.rb @@ -1,36 +1,13 @@ require "batch_actions/version" require "batch_actions/class_methods" +require "batch_actions/context" module BatchActions def batch_actions - return [] unless self.class.instance_variable_defined? :@batch_actions - - actions = self.class.instance_variable_get :@batch_actions - allowed = [] - - actions.each do |keyword, condition| - if instance_exec(&condition) - allowed << keyword - end - end - - allowed - end - - def batch_action - action = params[:name] - - raise "action is not allowed" unless batch_actions.include? action.to_sym - - send(:"batch_#{action}") + self.class.batch_actions.batch_actions end def self.included(base) base.extend ClassMethods - - if defined?(InheritedResources::Base) && - base < InheritedResources::Base - base.batch_model base.resource_class - end end end diff --git a/lib/batch_actions/class_methods.rb b/lib/batch_actions/class_methods.rb index 748009d..e974682 100644 --- a/lib/batch_actions/class_methods.rb +++ b/lib/batch_actions/class_methods.rb @@ -1,60 +1,17 @@ module BatchActions module ClassMethods - def batch_model(klass) - @batch_model = klass - end - - def batch_action(keyword, opts = {}, &block) - @batch_actions = {} if @batch_actions.nil? - - if opts.include? :model - model = opts[:model] - elsif !@batch_model.nil? - model = @batch_model - else - raise ArgumentError, "model must be specified" - end - - if block_given? - apply = block - else - apply = ->(objects) do - objects.each do |object| - object.send(keyword) - end - end - end - - if opts.include? :scope - scope = opts[:scope] - else - scope = ->(model) do - model.where(:id => params[:ids]) - end - end - - if opts.include? :if - condition = opts[:if] - else - condition = ->() do - if self.respond_to? :can? - can? keyword, model - else - true - end - end - end - - @batch_actions[keyword] = condition - define_method(:"batch_#{keyword}") do - result = instance_exec(&condition) - - raise "action is not allowed" unless result + def batch_actions(&block) + @batch_actions ||= BatchActions::Context.new + @batch_actions.configure(self, &block) if block_given? + @batch_actions + end - objects = instance_exec(model, &scope) - apply.call(objects) + def batch_action(action, options = {}) + batch_actions do + batch_action action, options end end + end end diff --git a/lib/batch_actions/context.rb b/lib/batch_actions/context.rb new file mode 100644 index 0000000..d6cd64a --- /dev/null +++ b/lib/batch_actions/context.rb @@ -0,0 +1,97 @@ +module BatchActions + class Context + attr_reader :batch_actions + + def initialize + @model = nil + @scope = default_scope + @respond_to = default_response + @param_name = :ids + + @batch_actions = {} + end + + def configure(controller, &block) + @controller_class = controller + instance_exec(&block) + end + + private + def param_name(name) + @param_name = name + end + + def model(resource_class) + @model = resource_class + end + + def scope(&block) + block_given? or raise ArgumentError, 'Need a block for batch_actions#scope' + @scope = block + end + + def respond_to(&block) + block_given? or raise ArgumentError, 'Need a block for batch_actions#respond_to' + @respond_to = block + end + + def batch_action(name, options = {}, &block) + scope = options[:scope] || @scope + response = options[:respond_to] || @respond_to + param_name = options[:param_name] || @param_name + action_name = options[:action_name] || :"batch_#{name}" + batch_method = options[:batch_method] || options[:action_name] || name + trigger = options[:trigger] || name + model = options[:model] || @model + + do_batch_stuff = block || ->(objects) do + results = objects.map do |object| + [object, object.send(batch_method)] + end + Hash[results] + end + + @controller_class.class_eval do + define_method action_name do + @ids = params[param_name] + @objects = instance_exec(model, @ids, &scope) + @results = do_batch_stuff.call(@objects) + + instance_exec(&response) + end + end + + @batch_actions[action_name] = trigger + end + + def dispatch_action(name = 'batch_action') + @controller_class.class_eval do + define_method name do + batch_actions.detect do |action, trigger| + if params.key?(trigger) + send(action) + end + end + end + end + end + + def default_scope + ->(model, ids) do + tail = if self.class.respond_to?(:resource_class) && model.nil? + end_of_association_chain + else + model + end + tail or raise ArgumentError, 'You must specify batch_actions#model to apply batch action on' + tail.where(id: ids) + end + end + + def default_response + ->() do + respond_with(@objects, location: url_for(action: :index)) + end + end + end +end \ No newline at end of file diff --git a/lib/batch_actions/version.rb b/lib/batch_actions/version.rb index 02667f0..c9d621f 100644 --- a/lib/batch_actions/version.rb +++ b/lib/batch_actions/version.rb @@ -1,3 +1,3 @@ module BatchActions - VERSION = "0.0.1" + VERSION = "0.0.2" end diff --git a/spec/batch_actions_spec.rb b/spec/batch_actions_spec.rb index 19ed3e0..0c747a5 100644 --- a/spec/batch_actions_spec.rb +++ b/spec/batch_actions_spec.rb @@ -1,155 +1,118 @@ require 'spec_helper' describe BatchActions do - it "generates batch actions" do - ctrl = mock_controller( - :ids => [ 1, 2 ] - ) do - batch_model TestModel - batch_action :test1 - end + it 'generates batch actions' do + params = {:ids => [ 1, 2 ]} - times = 0 - - TestModel.should_receive(:where).with({ :id => [ 1, 2 ]}).and_call_original - TestModel.any_instance.stub(:test1) { times += 1 } - - ctrl.batch_test1 - - times.should == 2 - end - - it "requires a model to be specified" do - expect do - mock_controller do + ctrl = mock_controller(params) do + batch_actions do + model TestModel batch_action :test1 end - end.to raise_error(ArgumentError) - end - - it "allows per-action override of a model" do - ctrl = mock_controller( - :ids => [ 1 ] - ) do - batch_model TestModel - - batch_action :test1 - batch_action :test2, :model => TestModel2 end - TestModel.should_receive(:where).with({ :id => [ 1 ]}).and_call_original - TestModel2.should_receive(:where).with({ :id => [ 1 ]}).and_call_original - - TestModel.any_instance.should_receive(:test1).and_return(nil) - TestModel2.any_instance.should_receive(:test2).and_return(nil) + mock.proxy(TestModel).where(id: params[:ids]).once + any_instance_of(TestModel) do |klass| + mock(klass).test1.times(params[:ids].length) + end - ctrl.batch_test1 - ctrl.batch_test2 + ctrl.batch_test1.should == 'Default response' end - it "allows to specify scope" do - scope_called = false - - instance = TestModel.new - instance.should_receive(:test1).and_return(nil) - - ctrl = mock_controller( - :ids => [ 1 ] - ) do - batch_model TestModel - - batch_action :test1, :scope => ->(model) do - scope_called = true - - [ instance ] + it 'requires a model to be specified' do + ctrl = mock_controller do + batch_actions do + batch_action :test1 end end - ctrl.batch_test1 - - scope_called.should be_true + -> { ctrl.batch_test1 }.should raise_error(ArgumentError) end - it "allows to override default apply" do - block_called = nil - - ctrl = mock_controller( - :ids => [ 1 ] - ) do - batch_model TestModel - - batch_action(:test1) do |objects| - block_called = objects + it 'does not require to specify model for inherited_resources' do + ctrl = mock_controller do + def self.resource_class + TestModel end - end - ctrl.batch_test1 + def resource_class + self.class.resource_class + end - block_called.should_not be_nil - block_called.length.should == 1 + batch_action :test1 + batch_action :test2 + end + -> { ctrl.batch_test1 }.should_not raise_error(ArgumentError) + -> { ctrl.batch_test2 }.should_not raise_error(ArgumentError) end - it "supports :if" do + it 'allows per-action override of params' do ctrl = mock_controller( - :ids => [ 1 ] + :ids_eq => [1, 2], + :the_ids => [1, 2] ) do - batch_model TestModel + batch_actions do + model TestModel - batch_action :test, :if => ->() { false } - end + param_name :the_ids + scope { |model, ids| model.where(other_id: ids) } + respond_to { 'Correct response' } - expect { ctrl.batch_test1 }.to raise_error - ctrl.batch_actions.should be_empty - end - - it "implements batch_actions" do - ctrl = mock_controller do - batch_model TestModel + batch_action :test1 + batch_action :test2, + param_name: :ids_eq, + model: TestModel2, + action_name: 'test_action', + batch_method: 'test_method', + scope: ->(model, ids) { model.somewhere(id: ids) }, + respond_to: -> { 'Test response overriden' } + end + end - batch_action :test1 - batch_action :test2 - batch_action :test3, :if => ->() { false } + mock.proxy(TestModel).where(other_id: [1, 2]).once + any_instance_of(TestModel) do |klass| + mock(klass).test1.twice end - ctrl.batch_actions.should == [ :test1, :test2 ] - end + mock.proxy(TestModel2).somewhere(id: [1, 2]).once + any_instance_of(TestModel2) do |klass| + mock(klass).test_method.twice + end - it "supports InheritedResources" do - expect do - mock_controller(:parent => InheritedResources::Base) do - batch_action :test1 - end - end.to_not raise_error + ctrl.batch_test1.should == 'Correct response' + ctrl.test_action.should == 'Test response overriden' end - it "supports CanCan" do + it 'implements #batch_actions' do ctrl = mock_controller do - batch_model TestModel - - batch_action :test1 - batch_action :test2 - - def can?(keyword, model) - keyword == :test1 + batch_actions do + batch_action :test1 + batch_action :test2 + batch_action :test3 end end - ctrl.batch_actions.should == [ :test1 ] + ctrl.batch_actions.should == { + batch_test1: :test1, + batch_test2: :test2, + batch_test3: :test3 + } end - it "implements batch_action" do - [ "test1", "test2" ].each do |action| - ctrl = mock_controller( - :ids => [ 1 ], - :name => action - ) do - batch_model TestModel + it 'implements dispatch action' do + ctrl = mock_controller(:test1 => true, ids: [1, 2]) do + batch_actions do + model TestModel + dispatch_action batch_action :test1 - batch_action :test2 end + end - TestModel.any_instance.should_receive(action.to_sym).and_return(nil) - ctrl.batch_action + any_instance_of(TestModel) do |klass| + mock(klass).test1.any_times end + proxy(ctrl).batch_test1.once + + ctrl.batch_action end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 78396a9..1790dd5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,25 +1,17 @@ require 'simplecov' -SimpleCov.start +SimpleCov.start require 'batch_actions' class TestModel def self.where(query) - query[:id].map { self.new } + query[query.keys.first].map { self.new } end end class TestModel2 - def self.where(query) - query[:id].map { self.new } - end -end - -module InheritedResources - class Base - def self.resource_class - TestModel - end + def self.somewhere(query) + query[query.keys.first].map { self.new } end end @@ -32,6 +24,14 @@ def mock_controller(params = {}, &block) def params self.class.instance_variable_get :@params end + + def respond_with(object, *args) + "Default response" + end + + def url_for(*args) + "" + end end mock_class.class_exec(&block) @@ -39,3 +39,7 @@ def params mock_class.new end + +RSpec.configure do |config| + config.mock_with :rr +end \ No newline at end of file