module Effective module CrudController extend ActiveSupport::Concern included do class << self def effective_resource @_effective_resource ||= Effective::Resource.new(controller_path) end def submits effective_resource.submits end end define_actions_from_routes define_callbacks :resource_render, :resource_save, :resource_error end module ClassMethods # Automatically respond to any action defined via the routes file def define_actions_from_routes resource = Effective::Resource.new(controller_path) resource.member_actions.each { |action| member_action(action) } resource.collection_actions.each { |action| collection_action(action) } end # https://github.com/rails/rails/blob/v5.1.4/actionpack/lib/abstract_controller/callbacks.rb def before_render(*names, &blk) _insert_callbacks(names, blk) { |name, options| set_callback(:resource_render, :before, name, options) } end def after_save(*names, &blk) _insert_callbacks(names, blk) { |name, options| set_callback(:resource_save, :after, name, options) } end def after_error(*names, &blk) _insert_callbacks(names, blk) { |name, options| set_callback(:resource_error, :after, name, options) } end # This controls the form submit options of effective_submit # It also controls the redirect path for any actions # # Effective::Resource will populate this with all member_post_actions # And you can control the details with this DSL: # # submit :approve, 'Save and Approve', unless: -> { approved? }, redirect: :show # # submit :toggle, 'Blacklist', if: -> { sync? }, class: 'btn btn-primary' # submit :toggle, 'Whitelist', if: -> { !sync? }, class: 'btn btn-primary' # submit :save, 'Save', success: -> { "#{self} was saved okay!" } def submit(action, commit = nil, args = {}) raise 'expected args to be a Hash or false' unless args.kind_of?(Hash) || args == false if commit == false submits.delete_if { |commit, args| args[:action] == action }; return end if args == false submits.delete(commit); return end if commit # Overwrite the default member action when given a custom commit submits.delete_if { |commit, args| args[:default] && args[:action] == action } end if args.key?(:if) && args[:if].respond_to?(:call) == false raise "expected if: to be callable. Try submit :approve, 'Save and Approve', if: -> { finished? }" end if args.key?(:unless) && args[:unless].respond_to?(:call) == false raise "expected unless: to be callable. Try submit :approve, 'Save and Approve', unless: -> { declined? }" end redirect = args.delete(:redirect_to) || args.delete(:redirect) # Remove redirect_to keyword. use redirect. args.merge!(action: action, redirect: redirect) (submits[commit] ||= {}).merge!(args) end # page_title 'My Title', only: [:new] def page_title(label = nil, opts = {}, &block) opts = label if label.kind_of?(Hash) raise 'expected a label or block' unless (label || block_given?) instance_exec do before_action(opts) do @page_title ||= (block_given? ? instance_exec(&block) : label).to_s end end end # resource_scope -> { current_user.things } # resource_scope -> { Thing.active.where(user: current_user) } # resource_scope do # { user_id: current_user.id } # end # Nested controllers? sure # resource_scope -> { User.find(params[:user_id]).things } # Return value should be: # a Relation: Thing.where(user: current_user) # a Hash: { user_id: current_user.id } def resource_scope(obj = nil, opts = {}, &block) raise 'expected a proc or block' unless (obj.respond_to?(:call) || block_given?) instance_exec do before_action(opts) do @_effective_resource_scope ||= instance_exec(&(block_given? ? block : obj)) end end end # Defines a function to handle a GET and POST request on this URL # Just add a member action to your routes, you shouldn't need to call this directly def member_action(action) define_method(action) do self.resource ||= resource_scope.find(params[:id]) EffectiveResources.authorize!(self, action, resource) @page_title ||= "#{action.to_s.titleize} #{resource}" request.get? ? run_callbacks(:resource_render) : member_post_action(action) end end # Defines a function to handle a GET and POST request on this URL # Handles bulk_ actions # Just add a member action to your routes, you shouldn't need to call this directly # You shouldn't need to call this directly def collection_action(action) define_method(action) do if params[:ids].present? self.resources ||= resource_scope.where(id: params[:ids]) end if effective_resource.scope?(action) self.resources ||= resource_scope.public_send(action) end self.resources ||= resource_scope.all EffectiveResources.authorize!(self, action, resource_klass) @page_title ||= "#{action.to_s.titleize} #{resource_plural_name.titleize}" request.get? ? run_callbacks(:resource_render) : collection_post_action(action) end end end def index @page_title ||= resource_plural_name.titleize EffectiveResources.authorize!(self, :index, resource_klass) self.resources ||= resource_scope.all if resource_datatable_class @datatable ||= resource_datatable_class.new(resource_datatable_attributes) @datatable.view = view_context end run_callbacks(:resource_render) end def new self.resource ||= resource_scope.new self.resource.assign_attributes( params.to_unsafe_h.except(:controller, :action, :id).select { |k, v| resource.respond_to?("#{k}=") } ) if params[:duplicate_id] duplicate = resource_scope.find(params[:duplicate_id]) EffectiveResources.authorize!(self, :show, duplicate) self.resource = duplicate_resource(duplicate) raise "expected duplicate_resource to return an unsaved new #{resource_klass} resource" unless resource.kind_of?(resource_klass) && resource.new_record? if (message = flash[:success].to_s).present? flash.delete(:success) flash.now[:success] = "#{message.chomp('.')}. Adding another #{resource_name.titleize} based on previous." end end @page_title ||= "New #{resource_name.titleize}" EffectiveResources.authorize!(self, :new, resource) run_callbacks(:resource_render) end def create self.resource ||= resource_scope.new @page_title ||= "New #{resource_name.titleize}" action = commit_action[:action] EffectiveResources.authorize!(self, action, resource) unless action == :save EffectiveResources.authorize!(self, :create, resource) if action == :save resource.created_by ||= current_user if resource.respond_to?(:created_by=) respond_to do |format| if save_resource(resource, action, send(resource_params_method_name)) request.format = :html if specific_redirect_path? format.html do flash[:success] ||= resource_flash(:success, resource, action) redirect_to(resource_redirect_path) end format.js do flash.now[:success] ||= resource_flash(:success, resource, action) reload_resource # create.js.erb end else flash.delete(:success) flash.now[:danger] ||= resource_flash(:danger, resource, action) run_callbacks(:resource_render) format.html { render :new } format.js {} # create.js.erb end end end def show self.resource ||= resource_scope.find(params[:id]) @page_title ||= resource.to_s EffectiveResources.authorize!(self, :show, resource) run_callbacks(:resource_render) end def edit self.resource ||= resource_scope.find(params[:id]) @page_title ||= "Edit #{resource}" EffectiveResources.authorize!(self, :edit, resource) run_callbacks(:resource_render) end def update self.resource ||= resource_scope.find(params[:id]) @page_title = "Edit #{resource}" action = commit_action[:action] EffectiveResources.authorize!(self, action, resource) unless action == :save EffectiveResources.authorize!(self, :update, resource) if action == :save respond_to do |format| if save_resource(resource, action, send(resource_params_method_name)) request.format = :html if specific_redirect_path? format.html do flash[:success] ||= resource_flash(:success, resource, action) redirect_to(resource_redirect_path) end format.js do flash.now[:success] ||= resource_flash(:success, resource, action) reload_resource # update.js.erb end else flash.delete(:success) flash.now[:danger] ||= resource_flash(:danger, resource, action) run_callbacks(:resource_render) format.html { render :edit } format.js { } # update.js.erb end end end def destroy self.resource = resource_scope.find(params[:id]) action = :destroy @page_title ||= "Destroy #{resource}" EffectiveResources.authorize!(self, action, resource) respond_to do |format| if save_resource(resource, action) request.format = :html if specific_redirect_path? format.html do flash[:success] ||= resource_flash(:success, resource, action) redirect_to(resource_redirect_path(action)) end format.js do flash.now[:success] ||= resource_flash(:success, resource, action) # destroy.js.erb end else flash.delete(:success) format.html do flash[:danger] = (flash.now[:danger].presence || resource_flash(:danger, resource, action)) redirect_to(resource_redirect_path(action)) end format.js do flash.now[:danger] ||= resource_flash(:danger, resource, action) # destroy.js.erb end end end end def member_post_action(action) raise 'expected post, patch or put http action' unless (request.post? || request.patch? || request.put?) respond_to do |format| if save_resource(resource, action, (send(resource_params_method_name) rescue {})) request.format = :html if specific_redirect_path? format.html do flash[:success] ||= resource_flash(:success, resource, action) redirect_to(resource_redirect_path(action)) end format.js do flash.now[:success] ||= resource_flash(:success, resource, action) reload_resource render_member_action(action) end else flash.delete(:success) flash.now[:danger] ||= resource_flash(:danger, resource, action) run_callbacks(:resource_render) format.html do if resource_edit_path && (referer_redirect_path || '').end_with?(resource_edit_path) @page_title ||= "Edit #{resource}" render :edit elsif resource_new_path && (referer_redirect_path || '').end_with?(resource_new_path) @page_title ||= "New #{resource_name.titleize}" render :new elsif resource_show_path && (referer_redirect_path || '').end_with?(resource_show_path) @page_title ||= resource_name.titleize render :show else @page_title ||= resource.to_s flash[:danger] = flash.now[:danger] redirect_to(referer_redirect_path || resource_redirect_path(action)) end end format.js { render_member_action(action) } end end end # Which member javascript view to render: #{action}.js or effective_resources member_action.js def render_member_action(action) view = lookup_context.template_exists?(action, _prefixes) ? action : :member_action render(view, locals: { action: action }) end # No attributes are assigned or saved. We purely call action! on the resource def collection_post_action(action) action = action.to_s.gsub('bulk_', '').to_sym raise 'expected post, patch or put http action' unless (request.post? || request.patch? || request.put?) raise "expected #{resource_name} to respond to #{action}!" if resources.to_a.present? && !resources.first.respond_to?("#{action}!") successes = 0 ActiveRecord::Base.transaction do successes = resources.select do |resource| begin resource.public_send("#{action}!") if EffectiveResources.authorized?(self, action, resource) rescue => e false end end.length end render json: { status: 200, message: "Successfully #{action_verb(action)} #{successes} / #{resources.length} selected #{resource_plural_name}" } end protected # This calls the appropriate member action, probably save!, on the resource. def save_resource(resource, action = :save, to_assign = {}, &block) raise "expected @#{resource_name} to respond to #{action}!" unless resource.respond_to?("#{action}!") resource.current_user ||= current_user if resource.respond_to?(:current_user=) ActiveRecord::Base.transaction do begin resource.assign_attributes(to_assign) if to_assign.present? if resource.public_send("#{action}!") == false raise("failed to #{action} #{resource}") end yield if block_given? run_callbacks(:resource_save) return true rescue => e if resource.respond_to?(:restore_attributes) && resource.persisted? resource.restore_attributes(['status', 'state']) end flash.delete(:success) flash.now[:danger] = flash_danger(resource, action, e: e) raise ActiveRecord::Rollback end end run_callbacks(:resource_error) false end def reload_resource self.resource.reload if resource.respond_to?(:reload) end # Should return a new resource based on the passed one def duplicate_resource(resource) resource.dup end def resource_flash(status, resource, action) submit = commit_action(action) message = submit[status].respond_to?(:call) ? instance_exec(&submit[status]) : submit[status] return if message.present? case status when :success then flash_success(resource, action) when :danger then flash_danger(resource, action) else raise "unknown resource flash status: #{status}" end end def resource_redirect_path(action = nil) submit = commit_action(action) redirect = submit[:redirect].respond_to?(:call) ? instance_exec(&submit[:redirect]) : submit[:redirect] commit_action_redirect = case redirect when :index ; resource_index_path when :edit ; resource_edit_path when :show ; resource_show_path when :new ; resource_new_path when :duplicate ; resource_duplicate_path when :back ; referer_redirect_path when :save ; [resource_edit_path, resource_show_path].compact.first when Symbol ; resource_action_path(submit[:action]) when String ; redirect else ; nil end return commit_action_redirect if commit_action_redirect.present? if action == :destroy return [referer_redirect_path, resource_index_path, root_path].compact.first end case params[:commit].to_s when 'Save' [resource_edit_path, resource_show_path, resource_index_path] when 'Save and Add New', 'Add New' [resource_new_path, resource_index_path] when 'Duplicate' [resource_duplicate_path, resource_index_path] when 'Continue', 'Save and Continue' [resource_index_path] else [referer_redirect_path, resource_edit_path, resource_show_path, resource_index_path] end.compact.first.presence || root_path end def referer_redirect_path url = request.referer.to_s return if (resource && resource.respond_to?(:destroyed?) && resource.destroyed? && url.include?("/#{resource.to_param}")) return if url.include?('duplicate_id=') return unless (Rails.application.routes.recognize_path(URI(url).path) rescue false) url end def resource_index_path effective_resource.action_path(:index) end def resource_new_path effective_resource.action_path(:new) end def resource_duplicate_path effective_resource.action_path(:new, duplicate_id: resource.id) end def resource_edit_path effective_resource.action_path(:edit, resource) end def resource_show_path effective_resource.action_path(:show, resource) end def resource_destroy_path effective_resource.action_path(:destroy, resource) end def resource_action_path(action) effective_resource.action_path(action.to_sym, resource) end def resource # @thing instance_variable_get("@#{resource_name}") end def resource=(instance) instance_variable_set("@#{resource_name}", instance) end def resources # @things send(:instance_variable_get, "@#{resource_plural_name}") end def resources=(instance) send(:instance_variable_set, "@#{resource_plural_name}", instance) end private def effective_resource @_effective_resource ||= Effective::Resource.new(controller_path) end def resource_name # 'thing' effective_resource.name end def resource_klass # Thing effective_resource.klass end def resource_human_name effective_resource.human_name end def resource_plural_name # 'things' effective_resource.plural_name end # Based on the incoming params[:commit] or passed action def commit_action(action = nil) self.class.submits[params[:commit].to_s] || self.class.submits[action.to_s] || self.class.submits.find { |_, v| v[:action] == action }.try(:last) || self.class.submits.find { |_, v| v[:action] == :save }.try(:last) || { action: (action || :save) } end def specific_redirect_path?(action = nil) submit = commit_action(action) (submit[:redirect].respond_to?(:call) ? instance_exec(&submit[:redirect]) : submit[:redirect]).present? end # Returns an ActiveRecord relation based on the computed value of `resource_scope` dsl method def resource_scope # Thing @_effective_resource_relation ||= ( relation = case @_effective_resource_scope # If this was initialized by the resource_scope before_filter when ActiveRecord::Relation @_effective_resource_scope when Hash effective_resource.klass.where(@_effective_resource_scope) when Symbol effective_resource.klass.send(@_effective_resource_scope) when nil effective_resource.klass.all else raise "expected resource_scope method to return an ActiveRecord::Relation or Hash" end unless relation.kind_of?(ActiveRecord::Relation) raise("unable to build resource_scope for #{effective_resource.klass || 'unknown klass'}.") end relation ) end def resource_datatable_attributes resource_scope.where_values_hash.symbolize_keys end def resource_datatable_class # ThingsDatatable effective_resource.datatable_klass end def resource_params_method_name ["#{resource_name}_params", "#{resource_plural_name}_params", 'permitted_params'].find { |name| respond_to?(name, true) } || 'params' end end end