From e9aa93f5eea7d0fdebc35a95c55b87966387db40 Mon Sep 17 00:00:00 2001 From: nakamura Date: Tue, 15 Sep 2026 16:15:24 +0000 Subject: [PATCH] Refactor Administrate application controller --- .../administrate/application_controller.rb | 311 +++--------------- .../concerns/administrate/authorizable.rb | 70 ++++ .../administrate/collection_paginator.rb | 15 + .../administrate/collection_searchable.rb | 27 ++ .../administrate/collection_sortable.rb | 45 +++ .../administrate/controller_deprecator.rb | 39 +++ .../concerns/administrate/controller_i18n.rb | 14 + .../administrate/dashboard_manager.rb | 30 ++ .../concerns/administrate/html_renderer.rb | 109 ++++++ .../concerns/administrate/resource_manager.rb | 128 +++++++ .../admin/log_entries_controller.rb | 2 +- 11 files changed, 524 insertions(+), 266 deletions(-) create mode 100644 app/controllers/concerns/administrate/authorizable.rb create mode 100644 app/controllers/concerns/administrate/collection_paginator.rb create mode 100644 app/controllers/concerns/administrate/collection_searchable.rb create mode 100644 app/controllers/concerns/administrate/collection_sortable.rb create mode 100644 app/controllers/concerns/administrate/controller_deprecator.rb create mode 100644 app/controllers/concerns/administrate/controller_i18n.rb create mode 100644 app/controllers/concerns/administrate/dashboard_manager.rb create mode 100644 app/controllers/concerns/administrate/html_renderer.rb create mode 100644 app/controllers/concerns/administrate/resource_manager.rb diff --git a/app/controllers/administrate/application_controller.rb b/app/controllers/administrate/application_controller.rb index 905385fad6..c38f652f41 100644 --- a/app/controllers/administrate/application_controller.rb +++ b/app/controllers/administrate/application_controller.rb @@ -1,300 +1,81 @@ module Administrate class ApplicationController < ActionController::Base + include Administrate::Authorizable + include Administrate::CollectionPaginator + include Administrate::CollectionSearchable + include Administrate::CollectionSortable + include Administrate::ControllerDeprecator + include Administrate::ControllerI18n + include Administrate::DashboardManager + include Administrate::HtmlRenderer + include Administrate::ResourceManager + protect_from_forgery with: :exception def index - authorize_resource(resource_class) - search_term = params[:search].to_s.strip - resources = filter_resources(scoped_resource, search_term: search_term) - resources = apply_collection_includes(resources) - resources = order.apply(resources) - resources = paginate_resources(resources) - @resources = resources - page = Administrate::Page::Collection.new(dashboard, order: order) - page.context = self - filters = Administrate::Search.new(scoped_resource, dashboard, search_term).valid_filters - - render locals: { - resources: resources, - search_term: search_term, - page: page, - show_search_bar: show_search_bar?, - filters: filters - } + respond_to do |format| + format.html { respond_to_index_html } + format.json { render json: collection_resources } + end end def show - @resource = resource = requested_resource - page = Administrate::Page::Show.new(dashboard, resource) - page.context = self - render locals: { - page: page - } + respond_to do |format| + format.html { respond_to_show_html } + format.json { render json: requested_resource } + end end def new - @resource = resource = new_resource.tap do |resource| - authorize_resource(resource) + respond_to do |format| + format.html { respond_to_new_html } end - - page = Administrate::Page::Form.new(dashboard, resource) - page.context = self - render locals: { - page: page - } end def edit - @resource = resource = requested_resource - page = Administrate::Page::Form.new(dashboard, resource) - page.context = self - render locals: { - page: page - } + respond_to do |format| + format.html { respond_to_edit_html } + end end def create - @resource = resource = new_resource(resource_params).tap do |resource| - authorize_resource(resource) - end + if save_built_resource + yield(built_resource) if block_given? - if resource.save - yield(resource) if block_given? - redirect_to( - after_resource_created_path(resource), - notice: translate_with_resource("create.success") - ) + respond_to do |format| + format.html { respond_to_create_html } + end else - page = Administrate::Page::Form.new(dashboard, resource) - page.context = self - render :new, locals: { - page: page - }, status: :unprocessable_entity + respond_to do |format| + format.html { respond_to_create_error_html } + end end end def update - @resource = resource = requested_resource - if resource.update(resource_params) - redirect_to( - after_resource_updated_path(resource), - notice: translate_with_resource("update.success"), - status: :see_other - ) - else - page = Administrate::Page::Form.new(dashboard, resource) - page.context = self - render :edit, locals: { - page: page - }, status: :unprocessable_entity - end - end - - def destroy - @resource = resource = requested_resource - if resource.destroy - flash[:notice] = translate_with_resource("destroy.success") - else - flash[:error] = resource.errors.full_messages.join("
") - end - redirect_to after_resource_destroyed_path(resource), status: :see_other - end - - private - - def filter_resources(resources, search_term:) - Administrate::Search.new( - resources, - dashboard, - search_term - ).run - end - - def after_resource_destroyed_path(_requested_resource) - {action: :index} - end - - def after_resource_created_path(requested_resource) - [namespace, requested_resource] - end - - def after_resource_updated_path(requested_resource) - [namespace, requested_resource] - end - - helper_method :nav_link_state - def nav_link_state(resource) - underscore_resource = resource.to_s.split("/").join("__") - (resource_name.to_s.pluralize == underscore_resource) ? :active : :inactive - end - - # Whether the named action route exists for the resource class. - # - # @param resource [Class, String, Symbol] A class of resources, or the name - # of a class of resources. - # @param action_name [String, Symbol] The name of an action that might be - # possible to perform on a resource or resource class. - # @return [Boolean] `true` if a route exists for the resource class and the - # action. `false` otherwise. - def existing_action?(resource, action_name) - routes.include?([resource.to_s.underscore.pluralize, action_name.to_s]) - end - helper_method :existing_action? - - def routes - @routes ||= Namespace.new(namespace).routes.to_set - end - - def records_per_page - params[:per_page] || 20 - end - - def order - @order ||= Administrate::Order.new( - sorting_attribute, - sorting_direction, - sorting_column: sorting_column( - dashboard_attribute(sorting_attribute) - ) - ) - end - - def sorting_column(dashboard_attribute) - return unless dashboard_attribute.try(:options) - - dashboard_attribute.options.fetch(:sorting_column) { - dashboard_attribute.options.fetch(:order, nil) - } - end - - def dashboard_attribute(attribute) - dashboard.attribute_types[attribute.to_sym] if attribute - end + requested_resource.assign_attributes(resource_params) - def sorting_attribute - sorting_params.fetch(:order) { default_sorting_attribute } - end - - def default_sorting_attribute - nil - end - - def sorting_direction - sorting_params.fetch(:direction) { default_sorting_direction } - end - - def default_sorting_direction - nil - end - - def sorting_params - Hash.try_convert(request.query_parameters[resource_name]) || {} - end - - def dashboard - @dashboard ||= dashboard_class.new.tap do |d| - d.context = self - end - end - - def requested_resource - @requested_resource ||= find_resource(params[:id]).tap do |resource| - authorize_resource(resource) - end - end - - def find_resource(param) - scoped_resource.find(param) - end - - def scoped_resource - resource_class.default_scoped - end - - def apply_collection_includes(relation) - resource_includes = dashboard.collection_includes - return relation if resource_includes.empty? - relation.includes(*resource_includes) - end - - def resource_params - params.require(resource_class.model_name.param_key) - .permit(dashboard.permitted_attributes(action_name)) - .transform_values { |v| read_param_value(v) } - end - - def read_param_value(data) - if data.is_a?(ActionController::Parameters) && data[:type] - if data[:type] == Administrate::Field::Polymorphic.to_s - GlobalID::Locator.locate(data[:value]) - else - raise "Unrecognised param data: #{data.inspect}" + if save_requested_resource + respond_to do |format| + format.html { respond_to_update_html } end - elsif data.is_a?(ActionController::Parameters) - data.transform_values { |v| read_param_value(v) } - elsif data.is_a?(String) && data.blank? - nil else - data + respond_to do |format| + format.html { respond_to_update_error_html } + end end end - delegate :dashboard_class, :resource_class, :resource_name, :namespace, - to: :resource_resolver - helper_method :namespace - helper_method :resource_name - helper_method :resource_class - - def resource_resolver - @resource_resolver ||= - Administrate::ResourceResolver.new(controller_path) - end - - def translate_with_resource(key) - t( - "administrate.controller.#{key}", - resource: resource_resolver.resource_title - ) - end - - def show_search_bar? - dashboard.attribute_types_for( - dashboard.all_attributes - ).any? { |_name, attribute| attribute.searchable? } - end - - # Whether the current user is authorized to perform the named action on the - # resource. - # - # @param _resource [ActiveRecord::Base, Class, String, Symbol] The - # temptative target of the action, or the name of its class. - # @param _action_name [String, Symbol] The name of an action that might be - # possible to perform on a resource or resource class. - # @return [Boolean] `true` if the current user is authorized to perform the - # action on the resource. `false` otherwise. - def authorized_action?(_resource, _action_name) - true - end - helper_method :authorized_action? - - def new_resource(params = {}) - resource_class.new(params) - end - helper_method :new_resource - - def authorize_resource(resource) - if authorized_action?(resource, action_name) - resource + def destroy + if requested_resource.destroy + respond_to do |format| + format.html { respond_to_destroy_html } + end else - raise Administrate::NotAuthorizedError.new( - action: action_name, - resource: resource - ) + respond_to do |format| + format.html { respond_to_destroy_error_html } + end end end - - def paginate_resources(resources) - resources.page(params[:_page]).per(records_per_page) - end end end diff --git a/app/controllers/concerns/administrate/authorizable.rb b/app/controllers/concerns/administrate/authorizable.rb new file mode 100644 index 0000000000..9c7c395924 --- /dev/null +++ b/app/controllers/concerns/administrate/authorizable.rb @@ -0,0 +1,70 @@ +module Administrate + module Authorizable + extend ActiveSupport::Concern + + included do + helper_method :existing_action? + helper_method :authorized_action? + end + + private + + # Whether the named action route exists for the resource class. + # + # @param resource [Class, String, Symbol] A class of resources, or the name + # of a class of resources. + # @param action_name [String, Symbol] The name of an action that might be + # possible to perform on a resource or resource class. + # @return [Boolean] `true` if a route exists for the resource class and the + # action. `false` otherwise. + def existing_action?(resource, action_name) + routes.include?([resource.to_s.underscore.pluralize, action_name.to_s]) + end + + # Whether the current user is authorized to perform the named action on the + # resource. + # + # @param _resource [ActiveRecord::Base, Class, String, Symbol] The + # tentative target of the action, or the name of its class. + # @param _action_name [String, Symbol] The name of an action that might be + # possible to perform on a resource or resource class. + # @return [Boolean] `true` if the current user is authorized to perform the + # action on the resource. `false` otherwise. + def authorized_action?(_resource, _action_name) + true + end + + # Override this if you want to authorize the scope. + # This will be used in all actions except for the `new` and `create` actions. + # + # @param scope [ActiveRecord::Relation] + # @return [ActiveRecord::Relation] + def authorize_scope(scope) + scope + end + + # Override this if you want to authorize the resource differently. + # This will be used to authorize the resource for all actions without `index`. + # In the case of `index`, it is used to authorize the resource class. + # + # @param resource [ActiveRecord::Base] + # @return [ActiveRecord::Base] + # @raise [Administrate::NotAuthorizedError] if the resource is not authorized. + def authorize_resource(resource) + if authorized_action?(resource, action_name) + resource + else + raise Administrate::NotAuthorizedError.new( + action: action_name, + resource: resource + ) + end + end + + protected + + def routes + @routes ||= Namespace.new(namespace).routes.to_set + end + end +end diff --git a/app/controllers/concerns/administrate/collection_paginator.rb b/app/controllers/concerns/administrate/collection_paginator.rb new file mode 100644 index 0000000000..e16678bcf1 --- /dev/null +++ b/app/controllers/concerns/administrate/collection_paginator.rb @@ -0,0 +1,15 @@ +module Administrate + module CollectionPaginator + extend ActiveSupport::Concern + + private + + def records_per_page + params[:per_page] || 20 + end + + def paginate_resources(resources) + resources.page(params[:_page]).per(records_per_page) + end + end +end diff --git a/app/controllers/concerns/administrate/collection_searchable.rb b/app/controllers/concerns/administrate/collection_searchable.rb new file mode 100644 index 0000000000..7cf1bc6c5d --- /dev/null +++ b/app/controllers/concerns/administrate/collection_searchable.rb @@ -0,0 +1,27 @@ +module Administrate + module CollectionSearchable + extend ActiveSupport::Concern + + private + + def filter_resources(resources) + Administrate::Search.new( + resources, + dashboard, + search_term + ).run + end + + def search_term + @search_term ||= params[:search].to_s.strip + end + + def filters + Administrate::Search.new( + scoped_resource, + dashboard, + search_term + ).valid_filters + end + end +end diff --git a/app/controllers/concerns/administrate/collection_sortable.rb b/app/controllers/concerns/administrate/collection_sortable.rb new file mode 100644 index 0000000000..7955e7b8c3 --- /dev/null +++ b/app/controllers/concerns/administrate/collection_sortable.rb @@ -0,0 +1,45 @@ +module Administrate + module CollectionSortable + extend ActiveSupport::Concern + + private + + def order + @order ||= Administrate::Order.new( + sorting_attribute, + sorting_direction, + sorting_column: sorting_column( + dashboard_attribute(sorting_attribute) + ) + ) + end + + def sorting_column(dashboard_attribute) + return unless dashboard_attribute.try(:options) + + dashboard_attribute.options.fetch(:sorting_column) { + dashboard_attribute.options.fetch(:order, nil) + } + end + + def sorting_attribute + sorting_params.fetch(:order) { default_sorting_attribute } + end + + def default_sorting_attribute + nil + end + + def sorting_direction + sorting_params.fetch(:direction) { default_sorting_direction } + end + + def default_sorting_direction + nil + end + + def sorting_params + Hash.try_convert(request.query_parameters[resource_name]) || {} + end + end +end diff --git a/app/controllers/concerns/administrate/controller_deprecator.rb b/app/controllers/concerns/administrate/controller_deprecator.rb new file mode 100644 index 0000000000..c8594b3d82 --- /dev/null +++ b/app/controllers/concerns/administrate/controller_deprecator.rb @@ -0,0 +1,39 @@ +module Administrate + module ControllerDeprecator + extend ActiveSupport::Concern + + included do + helper_method :valid_action? + helper_method :show_action? + helper_method :nav_link_state + end + + private + + # @deprecated Use {#existing_action} instead. Note that, in + # {#existing_action}, the order of parameters is reversed and there is + # no default value for the `resource` parameter. + def valid_action?(action_name, resource = resource_class) + Administrate.warn_of_deprecated_authorization_method(__method__) + existing_action?(resource, action_name) + end + + # @deprecated Use {#authorized_action} instead. Note that the order of + # parameters is reversed in {#authorized_action}. + def show_action?(action, resource) + Administrate.warn_of_deprecated_authorization_method(__method__) + authorized_action?(resource, action) + end + + def nav_link_state(resource) + underscore_resource = resource.to_s.split("/").join("__") + (resource_name.to_s.pluralize == underscore_resource) ? :active : :inactive + end + + def show_search_bar? + dashboard.attribute_types_for( + dashboard.all_attributes + ).any? { |_name, attribute| attribute.searchable? } + end + end +end diff --git a/app/controllers/concerns/administrate/controller_i18n.rb b/app/controllers/concerns/administrate/controller_i18n.rb new file mode 100644 index 0000000000..027d76c294 --- /dev/null +++ b/app/controllers/concerns/administrate/controller_i18n.rb @@ -0,0 +1,14 @@ +module Administrate + module ControllerI18n + extend ActiveSupport::Concern + + private + + def translate_with_resource(key) + t( + "administrate.controller.#{key}", + resource: resource_resolver.resource_title + ) + end + end +end diff --git a/app/controllers/concerns/administrate/dashboard_manager.rb b/app/controllers/concerns/administrate/dashboard_manager.rb new file mode 100644 index 0000000000..9d12360248 --- /dev/null +++ b/app/controllers/concerns/administrate/dashboard_manager.rb @@ -0,0 +1,30 @@ +module Administrate + module DashboardManager + extend ActiveSupport::Concern + + included do + delegate :dashboard_class, :resource_class, :resource_name, :namespace, + to: :resource_resolver + helper_method :namespace + helper_method :resource_name + helper_method :resource_class + end + + private + + def dashboard + @dashboard ||= dashboard_class.new.tap do |d| + d.context = self + end + end + + def dashboard_attribute(attribute) + dashboard.attribute_types[attribute.to_sym] if attribute + end + + def resource_resolver + @resource_resolver ||= + Administrate::ResourceResolver.new(controller_path) + end + end +end diff --git a/app/controllers/concerns/administrate/html_renderer.rb b/app/controllers/concerns/administrate/html_renderer.rb new file mode 100644 index 0000000000..5827c3fbc5 --- /dev/null +++ b/app/controllers/concerns/administrate/html_renderer.rb @@ -0,0 +1,109 @@ +module Administrate + module HtmlRenderer + extend ActiveSupport::Concern + + private + + def respond_to_index_html + render locals: { + resources: collection_resources, + search_term: search_term, + page: index_page, + show_search_bar: show_search_bar?, + filters: filters + } + end + + def respond_to_show_html + render locals: { + page: show_page + } + end + + def respond_to_new_html + render locals: { + page: new_page + } + end + + def respond_to_edit_html + render locals: { + page: edit_page + } + end + + def respond_to_create_html + redirect_to( + after_resource_created_path(built_resource), + notice: translate_with_resource("create.success") + ) + end + + def respond_to_create_error_html + render :new, locals: { + page: new_page + }, status: :unprocessable_entity + end + + def respond_to_update_html + redirect_to( + after_resource_updated_path(requested_resource), + notice: translate_with_resource("update.success"), + status: :see_other + ) + end + + def respond_to_update_error_html + render :edit, locals: { + page: edit_page + }, status: :unprocessable_entity + end + + def respond_to_destroy_html + flash[:notice] = translate_with_resource("destroy.success") + redirect_to after_resource_destroyed_path(requested_resource), status: :see_other + end + + def respond_to_destroy_error_html + flash[:error] = requested_resource.errors.full_messages.join("
") + redirect_to after_resource_destroyed_path(requested_resource), status: :see_other + end + + def index_page + page = Administrate::Page::Collection.new(dashboard, order: order) + page.context = self + collection_resources + page + end + + def show_page + page = Administrate::Page::Show.new(dashboard, requested_resource) + page.context = self + page + end + + def new_page + page = Administrate::Page::Form.new(dashboard, built_resource) + page.context = self + page + end + + def edit_page + page = Administrate::Page::Form.new(dashboard, requested_resource) + page.context = self + page + end + + def after_resource_destroyed_path(_requested_resource) + {action: :index} + end + + def after_resource_created_path(requested_resource) + [namespace, requested_resource] + end + + def after_resource_updated_path(requested_resource) + [namespace, requested_resource] + end + end +end diff --git a/app/controllers/concerns/administrate/resource_manager.rb b/app/controllers/concerns/administrate/resource_manager.rb new file mode 100644 index 0000000000..92407fb878 --- /dev/null +++ b/app/controllers/concerns/administrate/resource_manager.rb @@ -0,0 +1,128 @@ +module Administrate + module ResourceManager + extend ActiveSupport::Concern + + included do + helper_method :new_resource + end + + private + + def collection_resources + @collection_resources ||= begin + authorize_resource(resource_class) + resources = authorize_scope(scoped_resource) + resources = filter_resources(resources) + resources = apply_collection_includes(resources) + resources = order.apply(resources) + resources = paginate_resources(resources) + @resources = resources + resources + end + end + + def built_resource + @built_resource ||= new_resource(resource_params).tap do |resource| + authorize_resource(resource) + @resource = resource + end + end + + def requested_resource + @requested_resource ||= find_resource(params[:id]).tap do |resource| + authorize_resource(resource) + @resource = resource + end + end + + # Override this if you have certain roles that require a subset. + # This will be used in all actions except for the `new` and `create` actions. + # + # @return [ActiveRecord::Relation] + def scoped_resource + resource_class.default_scoped + end + + def new_resource(params = {}) + resource_class.new(params) + end + + # Override this method to specify custom lookup behavior. + # This will be used to set the resource for the `show`, `edit`, `update` and `destroy` actions. + # + # @param param [ActiveSupport::Parameter] + # @return [ActiveRecord::Base] + def find_resource(param) + authorize_scope(scoped_resource).find(param) + end + + def save_built_resource + built_resource.save(context: validation_contexts_on_create(built_resource)) + end + + def save_built_resource! + built_resource.save!(context: validation_contexts_on_create(built_resource)) + end + + def save_requested_resource + requested_resource.save(context: validation_contexts_on_update(requested_resource)) + end + + def save_requested_resource! + requested_resource.save!(context: validation_contexts_on_update(requested_resource)) + end + + # Override this if you want to provide additional validation contexts. + # + # @param resource [ActiveRecord::Base] The resource to be validated. + # @return [Array] The validation contexts to be used. + def validation_contexts_on_create(resource) + default_validation_contexts(resource) + end + + # Override this if you want to provide additional validation contexts. + # + # @param resource [ActiveRecord::Base] The resource to be validated. + # @return [Array] The validation contexts to be used. + def validation_contexts_on_update(resource) + default_validation_contexts(resource) + end + + def default_validation_contexts(resource) + nil + end + + def resource_params + attributes = params.fetch(resource_class.model_name.param_key, {}) + return {} if attributes.empty? + + attributes + .permit(dashboard.permitted_attributes(action_name)) + .transform_values { |v| read_param_value(v) } + end + + protected + + def apply_collection_includes(relation) + resource_includes = dashboard.collection_includes + return relation if resource_includes.empty? + relation.includes(*resource_includes) + end + + def read_param_value(data) + if data.is_a?(ActionController::Parameters) && data[:type] + if data[:type] == Administrate::Field::Polymorphic.to_s + GlobalID::Locator.locate(data[:value]) + else + raise "Unrecognised param data: #{data.inspect}" + end + elsif data.is_a?(ActionController::Parameters) + data.transform_values { |v| read_param_value(v) } + elsif data.is_a?(String) && data.blank? + nil + else + data + end + end + end +end diff --git a/spec/example_app/app/controllers/admin/log_entries_controller.rb b/spec/example_app/app/controllers/admin/log_entries_controller.rb index ae1705956b..c23e224aa0 100644 --- a/spec/example_app/app/controllers/admin/log_entries_controller.rb +++ b/spec/example_app/app/controllers/admin/log_entries_controller.rb @@ -1,6 +1,6 @@ module Admin class LogEntriesController < Admin::ApplicationController - def filter_resources(resources, search_term:) + def filter_resources(resources) return resources if search_term.blank? customer_ids = Customer.where(