From 0a3ef520a8d063fe88676d0b7818e61c1cc33383 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 00:24:12 +0200 Subject: [PATCH 1/8] Ask the input where a default value goes A default used to be written as a Ransack key: `default: { gteq_datetime: ... }`, `default: { id_in: [...] }`, `default: { cont: "acme" }`. That is the query language leaking into the filter declaration - the author wanted to say "show the active ones by default" and had to say how Ransack spells it. The input already knows. It is built against an empty search and asked, so a value is enough: `:select` answers `_eq`, `:check_boxes` answers `_in` under the association's primary key, `:date_range` answers both bounds, and `:string` answers whichever predicate heads its dropdown - which `filters:` on the filter, or `string_input_filters` on the resource or namespace, may have reordered. A hardcoded table would have been wrong in all three of those cases. Asking also carries a host app's own patches for free. One of ours rewrites DateRangeInput#gt_input_name to append `_datetime` for datetime columns, and the derived key follows it without the gem knowing anything about it. Two details the inputs do not volunteer. `current_filter` raises on a filter whose name already carries its predicate, since it goes looking for `title_eq_cont`, so `seems_searchable?` is checked first. And it prefers a predicate the current request already carries, which is the wrong answer for a default, so the input is built against an empty search rather than the live one. A Hash still names predicates outright, unchanged, for when the input's own is not the one you want. A value that cannot be placed - a single value for a two-ended input - raises rather than picking an end. --- README.md | 29 ++++--- .../data_access.rb | 80 +++++++++++++++++-- spec/integration/filter_defaults_spec.rb | 7 ++ spec/spec_helper.rb | 15 +++- spec/support/rails_template.rb | 22 ++--- 5 files changed, 124 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 1445487..bd917cb 100644 --- a/README.md +++ b/README.md @@ -24,21 +24,21 @@ Pre-1.0 and not on RubyGems yet: the option name and the shape of its value may ## Usage -**A scalar** applies to the filter name as it stands, which is what a name that already carries -its predicate needs: +A default is a **value**. Which Ransack key it lands under is the input's business, and the +input is asked - so a `:select` gets `_eq`, a `:check_boxes` gets `_in` under the association's +primary key, and a `:string` gets whichever predicate heads its dropdown, including when the +resource or the namespace has reordered that list: ```ruby -filter :state_eq, as: :select, collection: %w[active archived], default: "active" +filter :status, as: :select, default: "active" +filter :author, as: :check_boxes, default: [1, 2] +filter :created_at, as: :date_range, default: 1.week.ago..Time.current +filter :created_at, as: :date_range, default: 1.week.ago.. # lower bound only +filter :title, default: "acme" ``` -**A Hash** is keyed by predicate, for inputs that render more than one field. A `:date_range` -renders a `gteq` and an `lteq` field, and a `:check_boxes` submits as `q[author_id_in][]`, so the -filter name on its own does not identify a search key: - -```ruby -filter :created_at, as: :date_range, default: { gteq: -> { 1.week.ago.to_date } } -filter :author, as: :check_boxes, default: { id_in: [1, 2] } -``` +**A Range** fills a two-ended input, one bound per end; leave an end off and that end is left to +the admin. Handing a single value to a two-ended input raises, rather than picking an end for you. **A Proc** is evaluated against the controller on every request, and a `nil` result applies no filter, which is how a default is made conditional or read off the signed-in admin: @@ -48,6 +48,13 @@ filter :author_id_eq, default: -> { current_admin_user.id unless current_admin_u filter :queue_eq, default: -> { current_admin_user.default_queue } ``` +**A Hash** names the predicates outright, for when the input's own is not the one you want: + +```ruby +filter :title, default: { eq: "acme" } # rather than the _cont it would submit +filter :created_at, as: :date_range, default: { gteq: -> { 1.week.ago } } +``` + A filter hidden by `:if` or `:unless` imposes no default either — it would filter the collection with no control on screen to undo it. diff --git a/lib/active_admin_filters_defaults/data_access.rb b/lib/active_admin_filters_defaults/data_access.rb index 240917a..7796de9 100644 --- a/lib/active_admin_filters_defaults/data_access.rb +++ b/lib/active_admin_filters_defaults/data_access.rb @@ -66,20 +66,28 @@ def filter_default_values @filter_default_values ||= visible_filters.each_with_object({}) do |(attribute, options), result| next unless options.key?(:default) - add_filter_default_value(result, attribute, options[:default]) + add_filter_default_value(result, attribute, options) end end - # A scalar default applies to the filter name as it stands, which is what filters whose - # name already carries a predicate need: `filter :status_eq, default: "active"`. + # A default is a value, and where that value goes is the input's business: a `:select` + # submits `_eq`, a `:check_boxes` submits `_in` under the association's primary key, a + # `:string` submits whichever predicate heads its dropdown, which the resource or the + # namespace may have reordered. So the input is built and asked, rather than the author + # being made to spell a Ransack key out: # - # A Hash is keyed by predicate, which is what an input rendering more than one field - # needs, since the filter name alone does not identify a search key: - # `filter :created_at, as: :date_range, default: { gteq: -> { 1.week.ago.to_date } }`. + # filter :status, as: :select, default: "active" + # filter :author, as: :check_boxes, default: [1, 2] + # filter :created_at, as: :date_range, default: 1.week.ago..Time.current + # filter :created_at, as: :date_range, default: 1.week.ago.. # lower bound only + # + # A Hash still says the predicates outright, for when the input's own is not the one you + # want (`default: { eq: "acme" }` on a string filter that would otherwise search `_cont`). # # Only a Proc is called - unlike `:if` and `:unless`, a Symbol here is a value, not a # method to send. - def add_filter_default_value(result, attribute, default) + def add_filter_default_value(result, attribute, options) + default = options[:default] default = instance_exec(&default) if default.is_a?(Proc) return if default.nil? @@ -89,8 +97,64 @@ def add_filter_default_value(result, attribute, default) result["#{attribute}_#{predicate}"] = value unless value.nil? end else - result[attribute.to_s] = default + assign_derived_filter_default(result, attribute, options, default) + end + end + + private + + # A Range fills a two-ended input, one bound per end, and an endless or beginless one fills + # only the end it has. Anything else is a single value for a single-ended input. + def assign_derived_filter_default(result, attribute, options, default) + names = filter_search_keys(attribute, options) + + if default.is_a?(Range) + raise_filter_default_error(attribute, "a Range needs an input with two ends, and this one submits #{names.join(' and ')}") unless names.size == 2 + + result[names.first] = default.begin unless default.begin.nil? + result[names.last] = default.end unless default.end.nil? + else + raise_filter_default_error(attribute, "this input submits #{names.join(' and ')}, so a single value cannot say which to fill - give a Range, or name the predicates with a Hash") unless names.size == 1 + + result[names.first] = default end end + + # Asks the input itself, against an empty search: `current_filter` would otherwise answer + # with whatever predicate the current request happens to carry, and a default is about the + # request that carries none. + def filter_search_keys(attribute, options) + input = build_filter_input(attribute, options) + + names = + if input.respond_to?(:gt_input_name) + [input.gt_input_name, input.lt_input_name] + elsif input.respond_to?(:current_filter) && !input.seems_searchable? + # `:string` and `:numeric` let the admin pick the predicate from a dropdown, and the + # head of that list is what an untouched form submits. Unless the filter name already + # carries a predicate, in which case there is no dropdown - and asking anyway raises, + # since `current_filter` would go looking for `title_eq_cont`. + [input.current_filter] + else + [input.input_name] + end + + names.map { |name| name.to_s[/\Aq\[([^\]]+)\]/, 1] || name.to_s } + rescue StandardError => e + raise_filter_default_error(attribute, "could not work out which search key it submits (#{e.class}: #{e.message}) - name the predicate with a Hash instead") + end + + def build_filter_input(attribute, options) + search = active_admin_config.resource_class.ransack({}) + template = view_context + builder = ::ActiveAdmin::Filters::FormBuilder.new(:q, search, template, {}) + as = options[:as] || builder.send(:default_input_type, attribute) + builder.send(:namespaced_input_class, as) + .new(builder, template, search, :q, attribute, options.except(:default, :if, :unless)) + end + + def raise_filter_default_error(attribute, message) + raise ArgumentError, "filter :#{attribute} declares a `default:` but #{message}" + end end end diff --git a/spec/integration/filter_defaults_spec.rb b/spec/integration/filter_defaults_spec.rb index 6b7e04b..96eeeeb 100644 --- a/spec/integration/filter_defaults_spec.rb +++ b/spec/integration/filter_defaults_spec.rb @@ -34,6 +34,13 @@ end end + describe "a value that cannot be placed" do + it "says so instead of guessing" do + expect { visit "/admin/ambiguous_posts" } + .to raise_error(ArgumentError, /filter :published_date declares a `default:`.*single value cannot say which to fill/m) + end + end + describe "the form the admin sees" do it "is seeded with the default, so it can be read and edited like any other filter" do visit "/admin/posts" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5ca873b..12488bd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,12 +28,25 @@ class FakeController attr_reader :params, :active_admin_config - def initialize(params: {}, filters: {}) + # Deriving a search key means building a Formtastic input, which needs Rails. What the input + # would answer is supplied here instead, so these examples cover the step above derivation - + # splitting a value across the keys - while spec/integration covers the derivation itself. + def initialize(params: {}, filters: {}, search_keys: {}) @params = ActiveSupport::HashWithIndifferentAccess.new(params) @active_admin_config = Config.new(filters) + @search_keys = search_keys + end + + # Prepended below rather than defined here: DataAccess is itself prepended, so a method on + # the class would sit behind it in the lookup chain and never be reached. + module SearchKeys + def filter_search_keys(attribute, _options) + @search_keys.fetch(attribute) { [attribute.to_s] } + end end prepend ActiveAdminFiltersDefaults::DataAccess + prepend SearchKeys end RSpec.configure do |config| diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 22f2c3f..19f943f 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -61,7 +61,7 @@ def current_admin_user # `:check_boxes` submits `_in[]`. file "app/admin/posts.rb", <<~RUBY ActiveAdmin.register Post do - filter :title, default: { cont: "keep" } + filter :title, default: "keep" csv do column :title @@ -77,19 +77,19 @@ def current_admin_user end ActiveAdmin.register Post, as: "DatePost" do - filter :published_date, as: :date_range, default: { gteq: "2026-01-01" } + filter :published_date, as: :date_range, default: Date.new(2026, 1, 1).. end ActiveAdmin.register Post, as: "SelectPost" do - filter :status, as: :select, collection: %w[draft published], default: { eq: "published" } + filter :status, as: :select, collection: %w[draft published], default: "published" end ActiveAdmin.register Post, as: "CheckBoxesPost" do - filter :status, as: :check_boxes, collection: %w[draft published], default: { in: ["published"] } + filter :status, as: :check_boxes, collection: %w[draft published], default: ["published"] end ActiveAdmin.register Post, as: "BooleanPost" do - filter :starred, default: { eq: true } + filter :starred, default: true end ActiveAdmin.register Post, as: "AdminPreferencePost" do @@ -98,10 +98,10 @@ def current_admin_user end ActiveAdmin.register Post, as: "ConditionalPost" do - filter :title, default: { cont: "keep" }, if: -> { false } - filter :body, default: { cont: "keep" }, unless: -> { true } + filter :title, default: "keep", if: -> { false } + filter :body, default: "keep", unless: -> { true } filter :status, as: :select, collection: %w[draft published], - default: { eq: "published" }, if: -> { true } + default: "published", if: -> { true } end ActiveAdmin.register Post, as: "PlainPost" do @@ -110,7 +110,11 @@ def current_admin_user ActiveAdmin.register Post, as: "NoticePost" do default_filters_notice "Showing the kept ones by default" - filter :title, default: { cont: "keep" } + filter :title, default: "keep" + end + + ActiveAdmin.register Post, as: "AmbiguousPost" do + filter :published_date, as: :date_range, default: Date.new(2026, 1, 1) end ActiveAdmin.register Post, as: "AllHiddenPost" do From d9678d2e7e84321981f578c144afc404ec05e23a Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 00:30:59 +0200 Subject: [PATCH 2/8] Build one filter input builder per request, not per filter #view_context instantiates a fresh view class on every call, and deriving a search key was calling it once per defaulted filter. The builder depends only on the resource class and the view, which are the same for every filter in a request, so there is nothing to keep apart. The input now also takes its template and object from the builder rather than being handed separately built ones, which could have disagreed with it. Per-boot caching would go further - the derived keys do not depend on the request at all - but it needs a holder that resets when Active Admin reloads, and derivation only runs on requests that carry no filters, for filters that declare a default. That is one or two per page here, so it is an optimisation to make on a measurement rather than on a hunch. --- lib/active_admin_filters_defaults/data_access.rb | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/active_admin_filters_defaults/data_access.rb b/lib/active_admin_filters_defaults/data_access.rb index 7796de9..9ca5ea4 100644 --- a/lib/active_admin_filters_defaults/data_access.rb +++ b/lib/active_admin_filters_defaults/data_access.rb @@ -145,12 +145,20 @@ def filter_search_keys(attribute, options) end def build_filter_input(attribute, options) - search = active_admin_config.resource_class.ransack({}) - template = view_context - builder = ::ActiveAdmin::Filters::FormBuilder.new(:q, search, template, {}) + builder = filter_input_builder as = options[:as] || builder.send(:default_input_type, attribute) builder.send(:namespaced_input_class, as) - .new(builder, template, search, :q, attribute, options.except(:default, :if, :unless)) + .new(builder, builder.template, builder.object, :q, attribute, options.except(:default, :if, :unless)) + end + + # One per request rather than one per filter: #view_context builds a fresh view class every + # time it is called, and every defaulted filter would pay for another one. + # + # The search is empty on purpose - see #filter_search_keys. + def filter_input_builder + @filter_input_builder ||= ::ActiveAdmin::Filters::FormBuilder.new( + :q, active_admin_config.resource_class.ransack({}), view_context, {} + ) end def raise_filter_default_error(attribute, message) From e62f8079ad148739bb4201121581271be65b5502 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 00:32:05 +0200 Subject: [PATCH 3/8] Warn that a relative default has to be a Proc The examples handed out a literal Range for a moving window, which is wrong in the way that does not show up in a test: `filter` runs when the resource file is loaded, so `default: 1.week.ago..` pins the window to boot and lets it drift for the life of the process. Short-lived processes hide it; long-running workers do not. Caught while trying the branch on a real app, where the author had written the Proc form without being told to. --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bd917cb..c1d56f4 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,19 @@ resource or the namespace has reordered that list: ```ruby filter :status, as: :select, default: "active" filter :author, as: :check_boxes, default: [1, 2] -filter :created_at, as: :date_range, default: 1.week.ago..Time.current -filter :created_at, as: :date_range, default: 1.week.ago.. # lower bound only +filter :created_at, as: :date_range, default: -> { 1.week.ago..Time.current } +filter :created_at, as: :date_range, default: -> { 1.week.ago.. } # lower bound only filter :title, default: "acme" ``` **A Range** fills a two-ended input, one bound per end; leave an end off and that end is left to the admin. Handing a single value to a two-ended input raises, rather than picking an end for you. +Anything relative to now belongs in a Proc, as above. `filter` runs when the resource file is +loaded, so `default: 1.week.ago..` would pin the window to the moment the process booted and let +it drift for as long as that process lives - quietly, and worst on the long-running ones. A Proc +is re-read on every request. + **A Proc** is evaluated against the controller on every request, and a `nil` result applies no filter, which is how a default is made conditional or read off the signed-in admin: From 26b1cafd2e37e61e43e18553d99ce36a4d44672a Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 00:40:59 +0200 Subject: [PATCH 4/8] Move everything `default:` means into its own module DataAccess had grown into three jobs under one name: replacing Active Admin's #apply_filtering, working out what the declared defaults come to, and building Formtastic inputs to ask them which Ransack key they submit under. Only the first is data access; the last is view introspection that never touches the collection. DataAccess is now the one method this gem replaces, eighteen lines of it, and it reads `filtering_params`. FilterDefaults is everything that method means. It is included rather than prepended. Nothing in it replaces anything Active Admin defines, and every seam in it - #filtering_params, #filter_defaults_apply?, #visible_filters - is one a resource is meant to override in its own `controller do` block. Including puts the class ahead of the module, which is the order that wants. The unit harness got simpler in the process, which is the useful signal: it had to prepend its stand-in for the key derivation, because the real one sat in a prepended module and a method on the class could never be reached. Now it is a plain method on the class. --- lib/active_admin_filters_defaults.rb | 5 + .../data_access.rb | 156 +---------------- .../filter_defaults.rb | 162 ++++++++++++++++++ spec/spec_helper.rb | 12 +- 4 files changed, 175 insertions(+), 160 deletions(-) create mode 100644 lib/active_admin_filters_defaults/filter_defaults.rb diff --git a/lib/active_admin_filters_defaults.rb b/lib/active_admin_filters_defaults.rb index f31cc06..d8f4110 100644 --- a/lib/active_admin_filters_defaults.rb +++ b/lib/active_admin_filters_defaults.rb @@ -3,6 +3,7 @@ require "activeadmin" require "active_admin_filters_defaults/version" require "active_admin_filters_defaults/data_access" +require "active_admin_filters_defaults/filter_defaults" require "active_admin_filters_defaults/filters_form" module ActiveAdminFiltersDefaults @@ -11,7 +12,11 @@ module ActiveAdminFiltersDefaults # `add_filter` stores whatever options it is given without a whitelist, so `default:` needs no # registration of its own. ActiveAdmin.before_load do |_app| + # Prepended, because it replaces Active Admin's own #apply_filtering. ActiveAdmin::ResourceController.prepend ActiveAdminFiltersDefaults::DataAccess + # Included, because it only adds - and a resource overriding one of its seams in a + # `controller do` block should win, which is what including gives. + ActiveAdmin::ResourceController.include ActiveAdminFiltersDefaults::FilterDefaults ActiveAdmin::ResourceController.helper_method :filter_default_values, :visible_filters ActiveAdmin::Resource.prepend ActiveAdminFiltersDefaults::ResourceExtension diff --git a/lib/active_admin_filters_defaults/data_access.rb b/lib/active_admin_filters_defaults/data_access.rb index 9ca5ea4..7d3ecf7 100644 --- a/lib/active_admin_filters_defaults/data_access.rb +++ b/lib/active_admin_filters_defaults/data_access.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true module ActiveAdminFiltersDefaults - # Replaces ActiveAdmin::ResourceController::DataAccess#apply_filtering so that the collection - # is searched on #filtering_params rather than on `params[:q]` read directly. + # The single method this gem replaces: Active Admin searches the collection on `params[:q]` + # read directly, and it searches #filtering_params instead. Everything that method means is + # in FilterDefaults. # # The Ransack call is the Active Admin 3 one, which this gem targets: Active Admin 4 passes # `auth_object: active_admin_authorization` there as well. @@ -13,156 +14,5 @@ def apply_filtering(chain) @search = chain.ransack(filtering_params) @search.result end - - # The filter values the collection is searched with. Override to change what the index - # filters on without reaching into `params`. - # - # Whatever the request asked for wins over a default, so that an override of - # #filter_defaults_apply? which lets defaults through on a partly filtered request keeps - # the values that were actually asked for. - # - # @return [Hash, ActionController::Parameters] values passed to Ransack - def filtering_params - return params[:q] || {} unless filter_defaults_apply? - - defaults = filter_default_values - return params[:q] || {} if defaults.blank? - - requested = params[:q] - requested = requested.to_unsafe_h if requested.respond_to?(:to_unsafe_h) - defaults.merge(requested || {}) - end - - # Whether the request is one the declared defaults should apply to. Override to widen it - - # a resource that always carries its customer in `q`, say, still wants its defaults on the - # first visit: - # - # def filter_defaults_apply? - # super || params[:q].keys == %w[customer_id_eq] - # end - # - # `commit` marks a submission of the filters form: submitting it with every field blank - # sends no `q` at all, because the form disables empty fields on submit, so `commit` is - # what tells "show me everything" apart from a first visit. Clearing the filters drops - # `commit` along with `q`, so Clear Filters returns the page to its declared defaults. - def filter_defaults_apply? - params[:q].blank? && params[:commit].blank? - end - - # The filters of this resource that `:if` and `:unless` allow, which is both the set the - # filters form renders and the set that can impose a default - a filter that is not - # rendered does not filter the collection behind the admin's back. - # - # @return [Hash] filter attribute => filter options - def visible_filters - @visible_filters ||= active_admin_config.filters.reject do |_attribute, options| - (options.key?(:if) && !::MethodOrProcHelper.render_in_context(self, options[:if])) || - (options.key?(:unless) && ::MethodOrProcHelper.render_in_context(self, options[:unless])) - end - end - - # @return [Hash] the search values declared with `filter ..., default:` - def filter_default_values - @filter_default_values ||= visible_filters.each_with_object({}) do |(attribute, options), result| - next unless options.key?(:default) - - add_filter_default_value(result, attribute, options) - end - end - - # A default is a value, and where that value goes is the input's business: a `:select` - # submits `_eq`, a `:check_boxes` submits `_in` under the association's primary key, a - # `:string` submits whichever predicate heads its dropdown, which the resource or the - # namespace may have reordered. So the input is built and asked, rather than the author - # being made to spell a Ransack key out: - # - # filter :status, as: :select, default: "active" - # filter :author, as: :check_boxes, default: [1, 2] - # filter :created_at, as: :date_range, default: 1.week.ago..Time.current - # filter :created_at, as: :date_range, default: 1.week.ago.. # lower bound only - # - # A Hash still says the predicates outright, for when the input's own is not the one you - # want (`default: { eq: "acme" }` on a string filter that would otherwise search `_cont`). - # - # Only a Proc is called - unlike `:if` and `:unless`, a Symbol here is a value, not a - # method to send. - def add_filter_default_value(result, attribute, options) - default = options[:default] - default = instance_exec(&default) if default.is_a?(Proc) - return if default.nil? - - if default.is_a?(Hash) - default.each do |predicate, value| - value = instance_exec(&value) if value.is_a?(Proc) - result["#{attribute}_#{predicate}"] = value unless value.nil? - end - else - assign_derived_filter_default(result, attribute, options, default) - end - end - - private - - # A Range fills a two-ended input, one bound per end, and an endless or beginless one fills - # only the end it has. Anything else is a single value for a single-ended input. - def assign_derived_filter_default(result, attribute, options, default) - names = filter_search_keys(attribute, options) - - if default.is_a?(Range) - raise_filter_default_error(attribute, "a Range needs an input with two ends, and this one submits #{names.join(' and ')}") unless names.size == 2 - - result[names.first] = default.begin unless default.begin.nil? - result[names.last] = default.end unless default.end.nil? - else - raise_filter_default_error(attribute, "this input submits #{names.join(' and ')}, so a single value cannot say which to fill - give a Range, or name the predicates with a Hash") unless names.size == 1 - - result[names.first] = default - end - end - - # Asks the input itself, against an empty search: `current_filter` would otherwise answer - # with whatever predicate the current request happens to carry, and a default is about the - # request that carries none. - def filter_search_keys(attribute, options) - input = build_filter_input(attribute, options) - - names = - if input.respond_to?(:gt_input_name) - [input.gt_input_name, input.lt_input_name] - elsif input.respond_to?(:current_filter) && !input.seems_searchable? - # `:string` and `:numeric` let the admin pick the predicate from a dropdown, and the - # head of that list is what an untouched form submits. Unless the filter name already - # carries a predicate, in which case there is no dropdown - and asking anyway raises, - # since `current_filter` would go looking for `title_eq_cont`. - [input.current_filter] - else - [input.input_name] - end - - names.map { |name| name.to_s[/\Aq\[([^\]]+)\]/, 1] || name.to_s } - rescue StandardError => e - raise_filter_default_error(attribute, "could not work out which search key it submits (#{e.class}: #{e.message}) - name the predicate with a Hash instead") - end - - def build_filter_input(attribute, options) - builder = filter_input_builder - as = options[:as] || builder.send(:default_input_type, attribute) - builder.send(:namespaced_input_class, as) - .new(builder, builder.template, builder.object, :q, attribute, options.except(:default, :if, :unless)) - end - - # One per request rather than one per filter: #view_context builds a fresh view class every - # time it is called, and every defaulted filter would pay for another one. - # - # The search is empty on purpose - see #filter_search_keys. - def filter_input_builder - @filter_input_builder ||= ::ActiveAdmin::Filters::FormBuilder.new( - :q, active_admin_config.resource_class.ransack({}), view_context, {} - ) - end - - def raise_filter_default_error(attribute, message) - raise ArgumentError, "filter :#{attribute} declares a `default:` but #{message}" - end end end diff --git a/lib/active_admin_filters_defaults/filter_defaults.rb b/lib/active_admin_filters_defaults/filter_defaults.rb new file mode 100644 index 0000000..8038d16 --- /dev/null +++ b/lib/active_admin_filters_defaults/filter_defaults.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module ActiveAdminFiltersDefaults + # Everything `filter ..., default:` means: which requests the declared defaults apply to, what + # they come to on this one, and which Ransack key each value lands under. + # + # Included rather than prepended - none of it replaces anything Active Admin defines, and a + # resource is meant to be able to override the seams here in its own `controller do` block. + # The one method that does replace Active Admin's is in DataAccess. + module FilterDefaults + protected + + # The filter values the collection is searched with. Override to change what the index + # filters on without reaching into `params`. + # + # Whatever the request asked for wins over a default, so that an override of + # #filter_defaults_apply? which lets defaults through on a partly filtered request keeps + # the values that were actually asked for. + # + # @return [Hash, ActionController::Parameters] values passed to Ransack + def filtering_params + return params[:q] || {} unless filter_defaults_apply? + + defaults = filter_default_values + return params[:q] || {} if defaults.blank? + + requested = params[:q] + requested = requested.to_unsafe_h if requested.respond_to?(:to_unsafe_h) + defaults.merge(requested || {}) + end + + # Whether the request is one the declared defaults should apply to. Override to widen it - + # a resource that always carries its customer in `q`, say, still wants its defaults on the + # first visit: + # + # def filter_defaults_apply? + # super || params[:q].keys == %w[customer_id_eq] + # end + # + # `commit` marks a submission of the filters form: submitting it with every field blank + # sends no `q` at all, because the form disables empty fields on submit, so `commit` is + # what tells "show me everything" apart from a first visit. Clearing the filters drops + # `commit` along with `q`, so Clear Filters returns the page to its declared defaults. + def filter_defaults_apply? + params[:q].blank? && params[:commit].blank? + end + + # The filters of this resource that `:if` and `:unless` allow, which is both the set the + # filters form renders and the set that can impose a default - a filter that is not + # rendered does not filter the collection behind the admin's back. + # + # @return [Hash] filter attribute => filter options + def visible_filters + @visible_filters ||= active_admin_config.filters.reject do |_attribute, options| + (options.key?(:if) && !::MethodOrProcHelper.render_in_context(self, options[:if])) || + (options.key?(:unless) && ::MethodOrProcHelper.render_in_context(self, options[:unless])) + end + end + + # @return [Hash] the search values declared with `filter ..., default:` + def filter_default_values + @filter_default_values ||= visible_filters.each_with_object({}) do |(attribute, options), result| + next unless options.key?(:default) + + add_filter_default_value(result, attribute, options) + end + end + + private + + # A default is a value, and where that value goes is the input's business - see SearchKeys. + # + # filter :status, as: :select, default: "active" + # filter :author, as: :check_boxes, default: [1, 2] + # filter :created_at, as: :date_range, default: -> { 1.week.ago.. } + # + # A Hash still says the predicates outright, for when the input's own is not the one you + # want (`default: { eq: "acme" }` on a string filter that would otherwise search `_cont`). + # + # Only a Proc is called - unlike `:if` and `:unless`, a Symbol here is a value, not a + # method to send. + def add_filter_default_value(result, attribute, options) + default = options[:default] + default = instance_exec(&default) if default.is_a?(Proc) + return if default.nil? + + if default.is_a?(Hash) + default.each do |predicate, value| + value = instance_exec(&value) if value.is_a?(Proc) + result["#{attribute}_#{predicate}"] = value unless value.nil? + end + else + assign_derived_filter_default(result, attribute, options, default) + end + end + # A Range fills a two-ended input, one bound per end, and an endless or beginless one fills + # only the end it has. Anything else is a single value for a single-ended input. + def assign_derived_filter_default(result, attribute, options, default) + names = filter_search_keys(attribute, options) + + if default.is_a?(Range) + unless names.size == 2 + raise_filter_default_error(attribute, "a Range needs an input with two ends, and this one submits #{names.join(' and ')}") + end + + result[names.first] = default.begin unless default.begin.nil? + result[names.last] = default.end unless default.end.nil? + else + unless names.size == 1 + raise_filter_default_error(attribute, "this input submits #{names.join(' and ')}, so a single value cannot say which to fill - give a Range, or name the predicates with a Hash") + end + + result[names.first] = default + end + end + + # Asked against an empty search on purpose: `current_filter` otherwise answers with whatever + # predicate the current request happens to carry, and a default is about the request that + # carries none. + # + # @return [Array] one key, or two for a range input + def filter_search_keys(attribute, options) + input = build_filter_input(attribute, options) + + names = + if input.respond_to?(:gt_input_name) + [input.gt_input_name, input.lt_input_name] + elsif input.respond_to?(:current_filter) && !input.seems_searchable? + # `:string` and `:numeric` let the admin pick the predicate from a dropdown, and the + # head of that list is what an untouched form submits. Unless the filter name already + # carries a predicate, in which case there is no dropdown - and asking anyway raises, + # since `current_filter` would go looking for `title_eq_cont`. + [input.current_filter] + else + [input.input_name] + end + + names.map { |name| name.to_s[/\Aq\[([^\]]+)\]/, 1] || name.to_s } + rescue StandardError => e + raise_filter_default_error(attribute, "could not work out which search key it submits (#{e.class}: #{e.message}) - name the predicate with a Hash instead") + end + + def build_filter_input(attribute, options) + builder = filter_input_builder + as = options[:as] || builder.send(:default_input_type, attribute) + builder.send(:namespaced_input_class, as) + .new(builder, builder.template, builder.object, :q, attribute, options.except(:default, :if, :unless)) + end + + # One per request rather than one per filter: #view_context builds a fresh view class every + # time it is called, and every defaulted filter would pay for another one. + def filter_input_builder + @filter_input_builder ||= ::ActiveAdmin::Filters::FormBuilder.new( + :q, active_admin_config.resource_class.ransack({}), view_context, {} + ) + end + + def raise_filter_default_error(attribute, message) + raise ArgumentError, "filter :#{attribute} declares a `default:` but #{message}" + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 12488bd..3eaf8aa 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,6 +6,7 @@ # Pure Ruby, no Active Admin boot required - `visible_filters` resolves `:if` / `:unless` with it. require "active_admin/view_helpers/method_or_proc_helper" require "active_admin_filters_defaults/data_access" +require "active_admin_filters_defaults/filter_defaults" # `DataAccess` only needs `params`, `active_admin_config` and something that answers `ransack`. # That lets the unit suite run without booting Active Admin or Rails; the integration suite in @@ -37,16 +38,13 @@ def initialize(params: {}, filters: {}, search_keys: {}) @search_keys = search_keys end - # Prepended below rather than defined here: DataAccess is itself prepended, so a method on - # the class would sit behind it in the lookup chain and never be reached. - module SearchKeys - def filter_search_keys(attribute, _options) - @search_keys.fetch(attribute) { [attribute.to_s] } - end + # Plain override: FilterDefaults is included, so the class wins. + def filter_search_keys(attribute, _options) + @search_keys.fetch(attribute) { [attribute.to_s] } end prepend ActiveAdminFiltersDefaults::DataAccess - prepend SearchKeys + include ActiveAdminFiltersDefaults::FilterDefaults end RSpec.configure do |config| From d100b18c306d9729a887eeb0c70f70d060086692 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 01:31:58 +0200 Subject: [PATCH 5/8] Say what asking the input does and does not get you Two things found by trying the branch on real apps. A `:string` filter submits the head of its predicate dropdown, and an app that re-registers Ransack's `contains` / `equals` / `starts_with` aliases reorders that list - so a bare string default searches for equality there, not a substring. Correct, since it is what the form submits, and surprising to anyone reading the example here. A resource Ransack cannot search has no input to ask. Five pages in one app are ActiveResource models fronting an HTTP API; the derivation raises with the reason and the remedy rather than guessing a key. --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c1d56f4..2a31edd 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,22 @@ filter :author_id_eq, default: -> { current_admin_user.id unless current_admin_u filter :queue_eq, default: -> { current_admin_user.default_queue } ``` -**A Hash** names the predicates outright, for when the input's own is not the one you want: +Asking the input means you get whatever that input actually submits, which is not always the +predicate the docs of some other app would lead you to expect. A `:string` filter submits the +head of its dropdown, and an app that re-registers Ransack's aliases - `contains`, `equals`, +`starts_with` - reorders that list, so `default: "acme"` may search for equality rather than a +substring. That is correct, since it is what an untouched form submits there, but check it +rather than assume. + +Deriving needs a resource Ransack can search. A resource backed by something else - an +ActiveResource model standing in for an HTTP API, say - has no `ransack`, and the derivation +raises rather than guessing: + + filter :created_at declares a `default:` but could not work out which search key it submits + (NoMethodError: undefined method 'ransack' ...) - name the predicate with a Hash instead + +**A Hash** names the predicates outright, for that case and for when the input's own predicate +is not the one you want: ```ruby filter :title, default: { eq: "acme" } # rather than the _cont it would submit From 1b5734492ba910d7876ca31782e4e733857a1034 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 08:36:25 +0200 Subject: [PATCH 6/8] Check the admin can see the default, not just feel it The suite proved the collection came back filtered, and proved the value reached the form for exactly one input type. That is the wrong half to leave untested: a default that filters invisibly is the failure worth catching, and it is also what Clear Filters returning to the defaults rests on. Every type now asserts the rendered value - the selected option of a select and of a boolean, the checked box of a check boxes filter, both ends of a date range, the numeric field under the predicate its Hash named, the string field, and the one read off the signed-in admin. They discriminate: on a resource that declares no default the same field renders with no value attribute at all, so the assertions fail there. --- spec/integration/filter_defaults_spec.rb | 51 +++++++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/spec/integration/filter_defaults_spec.rb b/spec/integration/filter_defaults_spec.rb index 96eeeeb..3ffa926 100644 --- a/spec/integration/filter_defaults_spec.rb +++ b/spec/integration/filter_defaults_spec.rb @@ -41,13 +41,60 @@ end end - describe "the form the admin sees" do - it "is seeded with the default, so it can be read and edited like any other filter" do + # Filtering the collection is only half of it: the admin has to be able to see what the page + # decided on their behalf, and change it. Every input type is checked, because each renders + # its value differently and a default that filters invisibly is the thing to avoid. + describe "the value the admin sees in the form" do + it "shows it on a string filter" do visit "/admin/posts" expect(page).to have_field("q[title_cont]", with: "keep") end + it "shows it on a string filter whose name carries the predicate" do + visit "/admin/scalar_posts" + + expect(page).to have_field("q[title_cont]", with: "keep") + end + + it "shows it on a select filter" do + visit "/admin/select_posts" + + expect(page).to have_select("q[status_eq]", selected: "published") + end + + it "shows it on a check boxes filter" do + visit "/admin/check_boxes_posts" + + expect(page).to have_checked_field("q[status_in][]", with: "published") + expect(page).to have_unchecked_field("q[status_in][]", with: "draft") + end + + it "shows it on a boolean filter" do + visit "/admin/boolean_posts" + + expect(page).to have_select("q[starred_eq]", selected: "Yes") + end + + it "shows it on both ends of a date range filter" do + visit "/admin/date_posts" + + expect(page).to have_field("q[published_date_gteq]", with: "2026-01-01") + expect(page).to have_field("q[published_date_lteq]", with: "") + end + + it "shows it on a numeric filter, with the predicate its Hash named selected" do + visit "/admin/numeric_posts" + + expect(page).to have_field("q[position_gt]", with: "10") + end + + it "shows the one read off the signed in admin" do + visit "/admin/admin_preference_posts" + + expect(page).to have_select("q[status_eq]", selected: "published") + end + it "shows everything once the field is blanked and the form submitted" do visit "/admin/posts" fill_in "q[title_cont]", with: "" From ae053a3cf2282f9b445b577c774f3763480c4a26 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 08:53:35 +0200 Subject: [PATCH 7/8] Resolve a Proc :input_html before asking the input The filters form resolves `:input_html` against the view before it builds an input; the derivation was handing the Proc straight over. That is not merely incomplete, it is wrong in the way nothing reports: `Proc#[]` is `call`, so Formtastic asking `input_html[:multiple]` invoked the Proc, got a truthy Hash back, and a :select derived `_in` instead of `_eq` - a key that filters nothing, with no error. `input_html: proc { ... }` is ordinary in Active Admin and appears several times in one of the apps this was tried on, so this would have shipped and then failed quietly on whichever filter happened to combine the two. --- .../filter_defaults.rb | 14 +++++++++++++- spec/integration/filter_defaults_spec.rb | 11 +++++++++++ spec/support/rails_template.rb | 5 +++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/active_admin_filters_defaults/filter_defaults.rb b/lib/active_admin_filters_defaults/filter_defaults.rb index 8038d16..4c53cfa 100644 --- a/lib/active_admin_filters_defaults/filter_defaults.rb +++ b/lib/active_admin_filters_defaults/filter_defaults.rb @@ -142,9 +142,21 @@ def filter_search_keys(attribute, options) def build_filter_input(attribute, options) builder = filter_input_builder + options = filter_input_options(options, builder) as = options[:as] || builder.send(:default_input_type, attribute) builder.send(:namespaced_input_class, as) - .new(builder, builder.template, builder.object, :q, attribute, options.except(:default, :if, :unless)) + .new(builder, builder.template, builder.object, :q, attribute, options) + end + + # `:input_html` may be a Proc, which the filters form resolves against the view before it + # builds the input. Handing the Proc over instead is not merely incomplete, it is wrong in + # a way nothing reports: `Proc#[]` is `call`, so Formtastic asking `input_html[:multiple]` + # invokes it, gets a truthy Hash back, and a `:select` derives `_in` rather than `_eq`. + def filter_input_options(options, builder) + options = options.except(:default, :if, :unless) + return options unless options[:input_html].is_a?(Proc) + + options.merge(input_html: builder.template.instance_exec(&options[:input_html])) end # One per request rather than one per filter: #view_context builds a fresh view class every diff --git a/spec/integration/filter_defaults_spec.rb b/spec/integration/filter_defaults_spec.rb index 3ffa926..cc70d4b 100644 --- a/spec/integration/filter_defaults_spec.rb +++ b/spec/integration/filter_defaults_spec.rb @@ -34,6 +34,17 @@ end end + # `Proc#[]` is `call`, so a Proc left in `:input_html` answers Formtastic's `[:multiple]` + # check with a truthy Hash and a select derives `_in` instead of `_eq` - filtering nothing, + # silently. The Proc is resolved first, the way the filters form resolves it. + it "resolves a Proc :input_html before asking the input" do + visit "/admin/proc_input_html_posts" + + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + expect(page).to have_select("q[status_eq]", selected: "published") + end + describe "a value that cannot be placed" do it "says so instead of guessing" do expect { visit "/admin/ambiguous_posts" } diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 19f943f..25a95ea 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -117,6 +117,11 @@ def current_admin_user filter :published_date, as: :date_range, default: Date.new(2026, 1, 1) end + ActiveAdmin.register Post, as: "ProcInputHtmlPost" do + filter :status, as: :select, collection: %w[draft published], + input_html: proc { { class: "select2" } }, default: "published" + end + ActiveAdmin.register Post, as: "AllHiddenPost" do filter :title, if: -> { false } end From 671ca9fbbcef5281cee8161f378fe1920f742cd9 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 22 Sep 2026 09:39:42 +0200 Subject: [PATCH 8/8] Name the options an input must not see once The set was written out twice, in different orders - once where the filters form strips them, once where the derivation builds its input. They have to agree, and nothing made them: a fourth option added to the gem would have been remembered in one place and forgotten in the other, reaching Formtastic silently. Which is how the Proc in :input_html got through. FilterDefaults owns what `:default` means, so it owns the list. --- lib/active_admin_filters_defaults/filter_defaults.rb | 8 +++++++- lib/active_admin_filters_defaults/filters_form.rb | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/active_admin_filters_defaults/filter_defaults.rb b/lib/active_admin_filters_defaults/filter_defaults.rb index 4c53cfa..fb42894 100644 --- a/lib/active_admin_filters_defaults/filter_defaults.rb +++ b/lib/active_admin_filters_defaults/filter_defaults.rb @@ -8,6 +8,12 @@ module ActiveAdminFiltersDefaults # resource is meant to be able to override the seams here in its own `controller do` block. # The one method that does replace Active Admin's is in DataAccess. module FilterDefaults + # Read here and never handed to an input: `:default` is this gem's, and `:if` / `:unless` + # are Active Admin's but are resolved by #visible_filters before the form is given + # anything. Named once, because both the form and the derivation have to strip the same + # set and an option that drifts between them reaches Formtastic silently. + NOT_FOR_INPUT = %i[default if unless].freeze + protected # The filter values the collection is searched with. Override to change what the index @@ -153,7 +159,7 @@ def build_filter_input(attribute, options) # a way nothing reports: `Proc#[]` is `call`, so Formtastic asking `input_html[:multiple]` # invokes it, gets a truthy Hash back, and a `:select` derives `_in` rather than `_eq`. def filter_input_options(options, builder) - options = options.except(:default, :if, :unless) + options = options.except(*NOT_FOR_INPUT) return options unless options[:input_html].is_a?(Proc) options.merge(input_html: builder.template.instance_exec(&options[:input_html])) diff --git a/lib/active_admin_filters_defaults/filters_form.rb b/lib/active_admin_filters_defaults/filters_form.rb index a5dc8f5..6a79ce8 100644 --- a/lib/active_admin_filters_defaults/filters_form.rb +++ b/lib/active_admin_filters_defaults/filters_form.rb @@ -23,7 +23,7 @@ def filters_sidebar_section # method, so that this gem carries no copy of a method body that belongs to Active Admin. module ViewHelper def active_admin_filters_form_for(search, filters, options = {}) - super(search, filters.transform_values { |opts| opts.except(:if, :unless, :default) }, options) + super(search, filters.transform_values { |opts| opts.except(*FilterDefaults::NOT_FOR_INPUT) }, options) end end