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
113 changes: 113 additions & 0 deletions features/index/non_ar_filters.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
@filters
Feature: Filters on non-ActiveRecord resources

ActiveAdmin should let admins use the `filter` DSL on resources that
aren't ActiveRecord-backed (ActiveResource / API client wrappers,
ActiveModel objects, PORO query objects) — without monkey-patching.

The fixture model `ApiPost` mirrors the shape a real HTTP API client
would expose: `.fetch(page:, per_page:, filters...)` returns the page
of records plus a total_count reported by the upstream API, and the
controller adapts that response for ActiveAdmin's UI via
`Kaminari.paginate_array(records, total_count: ...)`.

Known limitation: the "Active Search" sidebar (filter chips with × to
clear) is tightly coupled to Ransack — it reads `Ransack::Condition`
objects (predicate / attributes / values / klass / reflect_on_all_
associations) that a plain `@search` object cannot supply. With the
nil guard in `Active#build_filters` the sidebar renders empty
("No filters applied") instead of raising; the filter form, "Clear
Filters" link, pagination, and table are all unaffected. Users who
want chips for non-AR resources need to either provide a Ransack-
shaped search object themselves or set `config.current_filters =
false` to hide the sidebar entirely.

Background:
Given I am logged in
And a configuration of:
"""
ActiveAdmin.register ApiPost do
actions :index

filter :title_cont, as: :string, label: "Title contains"
filter :status_eq, as: :select, label: "Status",
collection: %w[active inactive]

index do
column :id
column :title
column :status
end

controller do
# Non-AR resources opt out of AA's AR DataAccess path by
# overriding `find_collection`. Pagination is server-side:
# the model returns the page plus total_count, the controller
# hands that to Kaminari.paginate_array so AA's pagination UI
# works (page links, "Showing X-Y of Z", etc.).
def find_collection(*)
q = params[:q] || {}
@search = ApiPost::Search.new(
title_cont: q[:title_cont],
status_eq: q[:status_eq]
)
page = (params[:page] || 1).to_i
per_page = 5

result = ApiPost.fetch(
page: page,
per_page: per_page,
title_cont: @search.title_cont,
status_eq: @search.status_eq
)

Kaminari.paginate_array(
result.records,
total_count: result.total_count,
limit: result.limit,
offset: result.offset
).page(page).per(per_page)
end
end
end
"""

Scenario: Server-side pagination across multiple pages
When I am on the index page for api_posts
Then I should see "Alpha"
And I should see "Epsilon"
And I should not see "Zeta"
And I should see "Showing 1-5 of 7"
And I should see pagination page 2 link
And I should see the following filters:
| Title contains | string |
| Status | select |

When I follow "2"
Then I should see "Zeta"
And I should see "Eta"
And I should see "Showing 6-7 of 7"
And I should not see "Alpha"

Scenario: Filter narrows the result set to a single page (no pagination)
When I am on the index page for api_posts
Then I should see pagination page 2 link

When I select "inactive" from "Status"
And I press "Filter"
Then I should see "Beta"
And I should see "Epsilon"
And I should not see "Alpha"
And I should not see "Gamma"
And I should see "Showing all 2"
And I should not see pagination

Scenario: Filter narrows the result set to a single record
When I am on the index page for api_posts
And I fill in "Title contains" with "Bet"
And I press "Filter"
Then I should see "Beta"
And I should not see "Alpha"
And I should not see "Epsilon"
And I should see "Showing 1 of 1"
And I should not see pagination
3 changes: 2 additions & 1 deletion lib/active_admin/filters/active.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class Active
# @see ActiveAdmin::ResourceController::DataAccess#apply_filtering
def initialize(resource, search)
@resource = resource
@filters = build_filters(search.conditions)
# Ransack::Search uses method_missing for #conditions, so gate on the class.
@filters = search.is_a?(::Ransack::Search) ? build_filters(search.conditions) : []
@scopes = search.instance_variable_get(:@scope_args)
end

Expand Down
13 changes: 10 additions & 3 deletions lib/active_admin/filters/formtastic_addons.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ def column
# The below are custom methods that Formtastic does not provide.
#

# The resource class, unwrapped from Ransack
# The resource class, unwrapped from Ransack.
# Returns nil when @object is not a Ransack::Search-shaped object
# (e.g. ActiveResource/API-client wrappers, ActiveModel or PORO query
# objects), so the filter DSL can be used with non-AR resources.
def klass
@object.object.klass
@object.object.klass if @object.respond_to?(:object) && @object.object.respond_to?(:klass)
end

def polymorphic_foreign_type?(method)
Expand Down Expand Up @@ -65,8 +68,12 @@ def has_predicate?
end

# Ransack supports exposing selected scopes on your model for advanced searches.
# Uses Ransack::Context.for_class (which returns nil for classes Ransack
# has no adapter for) so non-AR resources are handled without rescuing.
def scope?
context = Ransack::Context.for klass
return false if klass.nil?

context = Ransack::Context.for_class(klass)
context.respond_to?(:ransackable_scope?) && context.ransackable_scope?(method.to_s, klass)
end

Expand Down
2 changes: 2 additions & 0 deletions lib/active_admin/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ def resource_table_name
end

def resource_column_names
return [] unless resource_class.respond_to?(:column_names)

resource_class.column_names
end

Expand Down
2 changes: 2 additions & 0 deletions lib/active_admin/resource/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ class Resource
module Attributes

def default_attributes
return {} unless resource_class.respond_to?(:columns)

resource_class.columns.each_with_object({}) do |c, attrs|
unless reject_col?(c)
name = c.name.to_sym
Expand Down
2 changes: 1 addition & 1 deletion lib/active_admin/views/components/table_for.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def initialize(*args, &block)
def sortable?
if @options.has_key?(:sortable)
!!@options[:sortable]
elsif @resource_class
elsif @resource_class.respond_to?(:column_names)
@resource_class.column_names.include?(sort_column_name)
else
@title.present?
Expand Down
3 changes: 3 additions & 0 deletions spec/support/rails_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@

copy_file File.expand_path("templates/models/tagging.rb", __dir__), "app/models/tagging.rb"

# Non-ActiveRecord PORO used in features/index/non_ar_filters.feature
copy_file File.expand_path("templates/models/api_post.rb", __dir__), "app/models/api_post.rb"

copy_file File.expand_path("templates/helpers/time_helper.rb", __dir__), "app/helpers/time_helper.rb"

copy_file File.expand_path("templates/models/company.rb", __dir__), "app/models/company.rb"
Expand Down
58 changes: 58 additions & 0 deletions spec/support/templates/models/api_post.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

# A non-ActiveRecord resource used in features to demonstrate that
# ActiveAdmin's filter DSL renders for resources that bypass the AR
# DataAccess path — HTTP-backed wrappers, SQL query objects, in-memory
# aggregations, etc.
#
# Pagination is owned by the model — `.fetch` returns the requested page
# plus total_count/limit/offset metadata, mirroring the shape an HTTP
# API client would carry alongside the page payload (response headers,
# meta blocks, cursors — wherever the upstream API surfaces the total).
# The controller then adapts that response for AA's UI via
# `Kaminari.paginate_array(records, total_count: ...)`.
class ApiPost
extend ActiveModel::Naming

Record = Struct.new(:id, :title, :status, keyword_init: true)

# Lightweight `@search` wrapper. ActiveAdmin's filter form pre-fills
# input values via `@search.<filter_name>`, so this needs attribute
# readers for every declared filter — Struct gives us that with no
# OpenStruct dependency.
Search = Struct.new(:title_cont, :status_eq, keyword_init: true)

ALL_RECORDS = [
Record.new(id: 1, title: "Alpha", status: "active"),
Record.new(id: 2, title: "Beta", status: "inactive"),
Record.new(id: 3, title: "Gamma", status: "active"),
Record.new(id: 4, title: "Delta", status: "active"),
Record.new(id: 5, title: "Epsilon", status: "inactive"),
Record.new(id: 6, title: "Zeta", status: "active"),
Record.new(id: 7, title: "Eta", status: "active")
].freeze

# Response shape mirrors what a real API client returns: the page of
# records plus the total_count reported by the upstream API — so the
# controller can hand AA's pagination UI an accurate total without
# loading every record.
Result = Struct.new(:records, :total_count, :limit, :offset, keyword_init: true)

def self.fetch(page: 1, per_page: 5, title_cont: nil, status_eq: nil)
page = [page.to_i, 1].max
per_page = per_page.to_i

records = ALL_RECORDS
records = records.select { |r| r.title.include?(title_cont) } if title_cont.present?
records = records.select { |r| r.status == status_eq } if status_eq.present?

offset = (page - 1) * per_page

Result.new(
records: records[offset, per_page] || [],
total_count: records.size,
limit: per_page,
offset: offset
)
end
end