Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Collection filters #947

Merged
merged 1 commit into from
Aug 16, 2019
Merged
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
28 changes: 28 additions & 0 deletions docs/customizing_dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,31 @@ end
```

Action is one of `new`, `edit`, `show`, `destroy`.

## Collection Filters

Resources can be filtered with pre-set filters. For example if we added:

```ruby
COLLECTION_FILTERS = {
inactive: ->(resources) { resources.where("login_at < ?", 1.week.ago) }
}
```

…to a dashboard, we can query the resources of that dashboard with:

```ruby
bob inactive:
```

…to find users named "bob" who hasn't logged in the last week.

If you already had the `inactive` scope you could define the filter like so to
take advantage of existing ActiveRecord scopes (and other class methods on the
resource class).

```ruby
COLLECTION_FILTERS = {
inactive: ->(resources) { resources.inactive }
}
```
89 changes: 83 additions & 6 deletions lib/administrate/search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,82 @@

module Administrate
class Search
class Query
attr_reader :filters

def blank?
terms.blank? && filters.empty?
end

def initialize(original_query)
@original_query = original_query
@filters, @terms = parse_query(original_query)
end

def original
@original_query
end

def terms
@terms.join(" ")
end

def to_s
original
end

private

def filter?(word)
word.match?(/^\w+:$/)
end

def parse_query(query)
filters = []
terms = []
query.to_s.split.each do |word|
if filter?(word)
filters << word.split(":").first
else
terms << word
end
end
[filters, terms]
end
end

def initialize(scoped_resource, dashboard_class, term)
@dashboard_class = dashboard_class
@scoped_resource = scoped_resource
@term = term
@query = Query.new(term)
end

def run
if @term.blank?
if query.blank?
@scoped_resource.all
else
@scoped_resource.joins(tables_to_join).where(query, *search_terms)
results = search_results(@scoped_resource)
results = filter_results(results)
results
end
end

private

def query
def apply_filter(filter, resources)
return resources unless filter
filter.call(resources)
end

def filter_results(resources)
query.filters.each do |filter_name|
filter = valid_filters[filter_name]
resources = apply_filter(filter, resources)
end
resources
end

def query_template
search_attributes.map do |attr|
table_name = query_table_name(attr)
attr_name = column_to_query(attr)
Expand All @@ -28,7 +87,7 @@ def query
end.join(" OR ")
end

def search_terms
def query_values
["%#{term.mb_chars.downcase}%"] * search_attributes.count
end

Expand All @@ -38,6 +97,20 @@ def search_attributes
end
end

def search_results(resources)
resources.
joins(tables_to_join).
where(query_template, *query_values)
end

def valid_filters
if @dashboard_class.const_defined?(:COLLECTION_FILTERS)
@dashboard_class.const_get(:COLLECTION_FILTERS).stringify_keys
else
{}
end
end

def attribute_types
@dashboard_class::ATTRIBUTE_TYPES
end
Expand Down Expand Up @@ -76,6 +149,10 @@ def association_search?(attribute)
].include?(attribute_types[attribute].deferred_class)
end

attr_reader :resolver, :term
def term
query.terms
end

attr_reader :resolver, :query
end
end
12 changes: 12 additions & 0 deletions lib/generators/administrate/dashboard/templates/dashboard.rb.erb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ class <%= class_name %>Dashboard < Administrate::BaseDashboard
%>
].freeze

# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze

# Overwrite this method to customize how <%= file_name.pluralize.humanize.downcase %> are displayed
# across all pages of the admin dashboard.
#
Expand Down
4 changes: 4 additions & 0 deletions spec/example_app/app/dashboards/customer_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class CustomerDashboard < Administrate::BaseDashboard
:password,
].freeze

COLLECTION_FILTERS = {
vip: ->(resources) { resources.where(kind: :vip) },
}.freeze

def display_resource(customer)
customer.name
end
Expand Down
28 changes: 28 additions & 0 deletions spec/features/search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,34 @@
expect(page).not_to have_content(mismatch.email)
end

scenario "admin searches with a filter", :js do
query = "vip:"
kind_match = create(:customer, kind: "vip", email: "[email protected]")
mismatch = create(:customer, kind: "standard", email: "[email protected]")
name_match_only = create(:customer, name: "VIP", email: "[email protected]")

visit admin_customers_path
fill_in :search, with: query
submit_search

expect(page).to have_content(kind_match.email)
expect(page).not_to have_content(mismatch.email)
expect(page).not_to have_content(name_match_only.email)
end

scenario "admin searches with an unknown filter", :js do
query = "whatevs:"
some_customer = create(:customer)
another_customer = create(:customer)

visit admin_customers_path
fill_in :search, with: query
submit_search

expect(page).to have_content(some_customer.email)
expect(page).to have_content(another_customer.email)
end

scenario "admin clears search" do
query = "foo"
mismatch = create(:customer, name: "someone")
Expand Down
51 changes: 51 additions & 0 deletions spec/lib/administrate/search_query_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
require "spec_helper"
require "administrate/search"

describe Administrate::Search::Query do
koppen marked this conversation as resolved.
Show resolved Hide resolved
koppen marked this conversation as resolved.
Show resolved Hide resolved
subject { described_class.new(query) }

context "when query is nil" do
let(:query) { nil }

it "treats nil as a blank string" do
expect(subject.terms).to eq("")
end
end

context "when query is blank" do
let(:query) { "" }

it "returns true if blank" do
expect(subject).to be_blank
end
end

context "when given a query with only terms" do
let(:query) { "foo bar" }

it "returns the parsed search terms" do
expect(subject.terms).to eq("foo bar")
end
end

context "when query includes filters" do
let(:query) { "vip: active:" }

it "is not blank" do
expect(subject).to_not be_blank
end

it "parses filter syntax" do
expect(subject.filters).to eq(["vip", "active"])
end
end

context "when query includes both filters and terms" do
let(:query) { "vip: example.com" }

it "splits filters and terms" do
expect(subject.filters).to eq(["vip"])
expect(subject.terms).to eq("example.com")
end
end
end
28 changes: 28 additions & 0 deletions spec/lib/administrate/search_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
require "rails_helper"
require "spec_helper"
require "support/constant_helpers"
require "administrate/field/belongs_to"
require "administrate/field/string"
require "administrate/field/email"
require "administrate/field/has_many"
require "administrate/field/has_one"
Expand All @@ -14,6 +17,10 @@ class MockDashboard
email: Administrate::Field::Email,
phone: Administrate::Field::Number,
}.freeze

COLLECTION_FILTERS = {
vip: ->(resources) { resources.where(kind: :vip) },
}.freeze
end

class MockDashboardWithAssociation
Expand Down Expand Up @@ -149,5 +156,26 @@ class User < ActiveRecord::Base; end
search.run
end
end

it "searches using a filter" do
begin

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/RedundantBegin: Redundant begin block detected.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, however if we want to maintain compatibility with rubies before 2.5, this begin block needs to be here.

class User < ActiveRecord::Base
scope :vip, -> { where(kind: :vip) }
end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"vip:")
expect(scoped_object).to \
receive(:where).
with(kind: :vip).
and_return(scoped_object)
expect(scoped_object).to receive(:where).and_return(scoped_object)

search.run
ensure
remove_constants :User
end
end
end
end