Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
# on 2026-03-31, so the floor there is 3.3 (security-only until 2027-03-31).
ruby: ['3.3', '3.4', '4.0']
rails: ['8.0.0', '8.1.0']
activeadmin: ['3.5.0']
activeadmin: ['3.5.0', '4.0.0.beta23']
env:
RAILS: ${{ matrix.rails }}
AA: ${{ matrix.activeadmin }}
Expand Down
14 changes: 11 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ default_rails_version = "8.0.0"
default_activeadmin_version = "3.5.0"

gem "rails", "~> #{ENV['RAILS'] || default_rails_version}"
gem "activeadmin", "~> #{ENV['AA'] || default_activeadmin_version}"
gem "sprockets-rails"
gem "sass-rails"
activeadmin_version = ENV["AA"] || default_activeadmin_version
gem "activeadmin", "~> #{activeadmin_version}"
# Active Admin 4 serves its assets through propshaft and an import map, Active Admin 3 through
# Sprockets and Sass.
if Gem::Version.new(activeadmin_version) >= Gem::Version.new("4.0.0.beta1")
gem "importmap-rails"
gem "propshaft"
else
gem "sprockets-rails"
gem "sass-rails"
end

group :test do
gem "capybara"
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ writes action results to `:notice`, and on a shared key the arriving message win
action's result would silence the notice. The key is read when the notice is written and
`flash_key:` on the resource still beats it.

On Active Admin 4 the stock layout renders only the `:error`, `:alert` and `:notice` flash
keys, so a key of your own needs an override of `app/views/active_admin/_flash_messages.html.erb`
that renders it. The `with:` and `require_any_filter` warnings go to `:alert` there, and to
`:warning` on Active Admin 3. An `:alert` that arrived with the request wins over the warning.
[activeadmin/activeadmin#9161](https://github.com/activeadmin/activeadmin/pull/9161) makes the
layout render every key; until a release ships it, the override is still needed.

## Required filters: the mandatory window

Some tables must never be queried without a bound - a log partitioned by date, a CDR table
Expand Down Expand Up @@ -363,7 +370,24 @@ Four prints in small type:
another predicate - wants `applied_filter_params`: the same values as an indifferent copy,
safe to read by symbol and to mutate. Both are helpers, so a view can call them too.

Tested against Active Admin 3.5 on Rails 8.0 and 8.1, under Ruby 3.3, 3.4 and 4.0.
## Active Admin 4

The gem works on Active Admin 3.5 and 4 from the same release; it ships no JavaScript or CSS.
On Active Admin 4 the filters section keeps Active Admin's own `h3.filters-form-title`, and
the Current Filters section (`#active_search_sidebar_section`) also shows when only defaults
apply. Page objects can select the filters form by `[data-filter-extensions-form]` and, on
Active Admin 4, its title by `[data-filter-extensions-title]`.

Tested against Active Admin 3.5 and 4.0.0.beta23 on Rails 8.0 and 8.1, under Ruby 3.3, 3.4
and 4.0.

The suite runs on Active Admin 3.5 by default. The `AA` and `RAILS` variables pick other
versions; `Gemfile.lock` is not committed, so re-resolve the bundle when switching:

```sh
AA=4.0.0.beta23 bundle update activeadmin
AA=4.0.0.beta23 bundle exec rake spec
```

## License

Expand Down
2 changes: 1 addition & 1 deletion active_admin_filter_extensions.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Gem::Specification.new do |s|
"the URL."

s.required_ruby_version = ">= 3.3"
s.add_dependency "activeadmin", ">= 3.5", "< 4"
s.add_dependency "activeadmin", ">= 3.5", "< 5.0.0.a"

s.files = `git ls-files`.split("\n")
s.require_paths = ["lib"]
Expand Down
23 changes: 20 additions & 3 deletions lib/active_admin_filter_extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ class << self
def notice_flash_key
@notice_flash_key ||= :notice
end

# Active Admin 4 moved the filters form helper and the Current Filters section.
def aa_v4?
return @aa_v4 if defined?(@aa_v4)

@aa_v4 = Gem::Version.new(ActiveAdmin::VERSION) >= Gem::Version.new("4.0.0.beta1")
end

# Active Admin 4's layout renders only the :error, :alert and :notice flash keys.
def warning_flash_key
aa_v4? ? :alert : :warning
end
end
end

Expand All @@ -49,7 +61,6 @@ def notice_flash_key
ActiveAdmin::ResourceController.helper_method :filtering_params, :applied_filter_params,
:filter_default_values, :visible_filters

ActiveAdmin::Resource.prepend ActiveAdminFilterExtensions::ResourceExtension
ActiveAdmin::ResourceDSL.include ActiveAdminFilterExtensions::Policies::ResourcePolicyDSL
ActiveAdmin::Resource.include ActiveAdminFilterExtensions::Policies::ResourcePolicySettings
ActiveAdmin::ResourceDSL.include ActiveAdminFilterExtensions::Notice
Expand All @@ -58,6 +69,12 @@ def notice_flash_key
# decision - and therefore the URL - is computed from.
ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::Policies::Controller
ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::Redirect
ActiveAdmin::Filters::ViewHelper.prepend ActiveAdminFilterExtensions::ViewHelper
ActiveAdmin::Filters::ActiveSidebar.prepend ActiveAdminFilterExtensions::ActiveSidebar
if ActiveAdminFilterExtensions.aa_v4?
ActiveAdmin::Resource.prepend ActiveAdminFilterExtensions::ResourceExtensionV4
ActiveAdmin::FormHelper.prepend ActiveAdminFilterExtensions::ViewHelper
else
ActiveAdmin::Resource.prepend ActiveAdminFilterExtensions::ResourceExtension
ActiveAdmin::Filters::ViewHelper.prepend ActiveAdminFilterExtensions::ViewHelper
ActiveAdmin::Filters::ActiveSidebar.prepend ActiveAdminFilterExtensions::ActiveSidebar
end
end
5 changes: 1 addition & 4 deletions lib/active_admin_filter_extensions/data_access.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@ module ActiveAdminFilterExtensions
# 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.
module DataAccess
protected

def apply_filtering(chain)
@search = chain.ransack(filtering_params)
@search = chain.ransack(filtering_params, **filter_ransack_options)
# An :empty policy keeps the request intact - the form still shows what the admin
# typed - and blanks the collection instead.
return @search.result.none if filter_policy_render_empty?
Expand Down
31 changes: 30 additions & 1 deletion lib/active_admin_filter_extensions/filters_form.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

module ActiveAdminFilterExtensions
# Stable hooks for page objects, so specs need not select on Active Admin's CSS classes.
FORM_OPTIONS = { html: { "data-filter-extensions-form" => "" } }.freeze

# The filters sidebar hands the form the filters that `:if` and `:unless` allow, so the same
# set is rendered and consulted for defaults, and the conditions are resolved once.
module ResourceExtension
Expand All @@ -11,11 +14,37 @@ def filters_sidebar_section
# Passed positionally rather than splatted: the section is shown when the resource has
# any filter at all, and `**{}` would drop the argument entirely once `:if` and
# `:unless` have denied every one of them.
active_admin_filters_form_for assigns[:search], visible_filters
active_admin_filters_form_for assigns[:search], visible_filters, FORM_OPTIONS
end
end
end

# Active Admin 4's sidebar: the section title is rendered inside the block, as
# `h3.filters-form-title`, and the Current Filters panel is a section of its own.
module ResourceExtensionV4
private

def filters_sidebar_section
name = :filters
ActiveAdmin::SidebarSection.new name, only: :index, if: -> { active_admin_config.filters.any? } do
title = I18n.t("active_admin.sidebars.#{name}", default: name.to_s.titlecase)
h3 title, class: "filters-form-title", "data-filter-extensions-title" => ""
active_admin_filters_form_for assigns[:search], visible_filters, FORM_OPTIONS
end
end

# Gated on `params[:q]` like ActiveSidebar below; only its condition is widened.
def active_search_sidebar_section
section = super
shown = section.options[:if]
section.options[:if] = lambda {
instance_exec(&shown) ||
(active_admin_config.current_filters_enabled? && filter_default_values.present?)
}
section
end
end

# The form renders what it is handed: `:if` and `:unless` were already resolved by
# #visible_filters, and the policy keys - merged flat into these options by
# #merge_filter_extensions - are not Formtastic's business. required: in particular is also a
Expand Down
17 changes: 12 additions & 5 deletions lib/active_admin_filter_extensions/policies.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ def enforce_require_any_filter
# Active Admin reads a q-less index as "this table has no rows" and renders the blank
# slate, Create link and all; a present-but-empty q renders an empty result instead.
params[:q] ||= {}
flash.now[:warning] =
notice.is_a?(String) ? notice : I18n.t(notice, scope: :active_admin_filter_extensions,
default: :require_any_filter)
unless notice.is_a?(String)
notice = I18n.t(notice, scope: :active_admin_filter_extensions, default: :require_any_filter)
end
flash_filter_warning(notice)
end

# Ransack silently ignores a condition nobody declared - junk, a stale bookmark after
Expand All @@ -169,6 +170,13 @@ def any_declared_filter_answered?
end
end

# On Active Admin 4 the warning shares :alert with the app's own messages. A message that
# arrived with the request wins, as it does for the notice.
def flash_filter_warning(message)
key = ActiveAdminFilterExtensions.warning_flash_key
flash.now[key] = message if flash[key].blank?
end

def enforce_with_groups(q)
# Membership is judged on the normalized request: a blank-array partner would pass a
# raw present? check while Ransack drops it - half the group executing silently.
Expand All @@ -181,8 +189,7 @@ def enforce_with_groups(q)
filter_policy_errors << keys
filter_policy_reset_keys.concat(keys)
names = keys.map { |k| strip_ransack_predicate(k).humanize }.uniq.join(" and ")
flash.now[:warning] = I18n.t(:given_together, scope: :active_admin_filter_extensions,
filters: names)
flash_filter_warning(I18n.t(:given_together, scope: :active_admin_filter_extensions, filters: names))
present.flatten.each { |key| q.delete(key) }
end
end
Expand Down
9 changes: 8 additions & 1 deletion lib/active_admin_filter_extensions/search_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,17 @@ def filter_input_options(options, builder)
# 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, {}
:q, active_admin_config.resource_class.ransack({}, **filter_ransack_options), view_context, {}
)
end

# What Active Admin passes to Ransack next to the search: version 4 hands over its
# authorization adapter, so a model's `ransackable_attributes(auth_object)` can depend on
# the admin; version 3 passes nothing.
def filter_ransack_options
ActiveAdminFilterExtensions.aa_v4? ? { auth_object: active_admin_authorization } : {}
end

def raise_filter_default_error(attribute, message)
raise ArgumentError, "filter :#{attribute} declares a `default:` but #{message}"
end
Expand Down
21 changes: 21 additions & 0 deletions spec/integration/ransack_auth_object_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require "rails_helper"

# Active Admin 4 passes its authorization adapter to Ransack, so a model's allowlist can depend
# on the admin. The gem's own search and its search-key derivation must pass it on as well.
RSpec.describe "the Ransack auth object", type: :feature do
it "reaches the model's allowlist from every search the index builds" do
seen = []
allow(Post).to receive(:ransackable_attributes).and_wrap_original do |original, auth_object = nil|
seen << auth_object
original.call(auth_object)
end

visit "/admin/string_posts"

expected = ActiveAdminFilterExtensions.aa_v4? ? ActiveAdmin::AuthorizationAdapter : NilClass
expect(seen).not_to be_empty
expect(seen).to all(be_a(expected))
end
end
8 changes: 8 additions & 0 deletions spec/integration/require_any_filter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@
expect(page).to have_no_link("Create one")
end

it "leaves an alert that arrived with the request in place" do
# On Active Admin 4 the warning and an action's alert share the :alert key.
visit "/admin/picky_posts/poke"

expect(page).to have_content("poked")
expect(page).to have_no_content("keep me")
end

it "answers once asked" do
visit "/admin/picky_posts?q%5Btitle_cont%5D=keep"

Expand Down
54 changes: 54 additions & 0 deletions spec/integration/sidebar_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

require "rails_helper"

# The sidebar markup moved in Active Admin 4: the Current Filters section has its own id and the
# filters section renders its title inside the block.
RSpec.describe "the filters sidebar", type: :feature do
let(:aa_v4) { ActiveAdminFilterExtensions.aa_v4? }
let(:current_filters) { aa_v4 ? "#active_search_sidebar_section" : "#search_status_sidebar_section" }

it "marks the filters form with a data hook" do
visit "/admin/string_posts"

expect(page).to have_css("#filters_sidebar_section form[data-filter-extensions-form]")
end

it "shows the Current Filters panel when only defaults apply" do
visit "/admin/string_posts"

expect(page).to have_css(current_filters, text: "keep")
end

it "keeps the Current Filters panel off where the resource turned it off" do
visit "/admin/quiet_default_posts"
Comment on lines +18 to +24

expect(page).to have_css("#filters_sidebar_section")
expect(page).to have_no_css(current_filters)
end

it "hides the Current Filters panel when nothing is filtered" do
visit "/admin/no_defaults_posts"

expect(page).to have_css("#filters_sidebar_section")
expect(page).to have_no_css(current_filters)
end

it "keeps the Current Filters panel for a filter from the request" do
visit "/admin/no_defaults_posts?q%5Btitle_cont%5D=keep"

expect(page).to have_css(current_filters, text: "keep")
end

it "renders the filters title once" do
visit "/admin/string_posts"

if aa_v4
expect(page).to have_css("#filters_sidebar_section h3.filters-form-title[data-filter-extensions-title]",
count: 1, text: "Filters")
else
expect(page).to have_no_css("#filters_sidebar_section .filters-form-title")
expect(page).to have_css("#filters_sidebar_section h3", count: 1, text: "Filters")
end
end
end
2 changes: 1 addition & 1 deletion spec/integration/with_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def query_of(url)
# PairedPost declares with: on both members - the two declarations are one group.
visit "/admin/paired_posts?q%5Bstatus_eq%5D=draft"

expect(page).to have_css(".flash_warning", count: 1)
expect(page).to have_css(".flash_#{ActiveAdminFilterExtensions.warning_flash_key}", count: 1)
end

it "does not count a blank-array partner as answered" do
Expand Down
3 changes: 2 additions & 1 deletion spec/rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
ENV["RAILS_ENV"] = "test"
require "rails"
ENV["RAILS"] = Rails.version
ENV["RAILS_ROOT"] = File.expand_path("rails/rails-#{ENV['RAILS']}", __dir__)
aa_major = Gem.loaded_specs["activeadmin"].version.segments.first
ENV["RAILS_ROOT"] = File.expand_path("rails/rails-#{ENV['RAILS']}-aa#{aa_major}", __dir__)

unless File.exist?(ENV["RAILS_ROOT"])
# A generation that dies midway leaves a partial app the File.exist? guard would then skip
Expand Down
3 changes: 3 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ def filter_search_keys(attribute, _options)
@search_keys.fetch(attribute) { [attribute.to_s] }
end

# The real one asks Active Admin's version, which this suite does not load.
def filter_ransack_options = {}

prepend ActiveAdminFilterExtensions::DataAccess
include ActiveAdminFilterExtensions::Policies::Controller
include ActiveAdminFilterExtensions::FilterOptions
Expand Down
Loading
Loading