Skip to content

Commit

Permalink
Add search filters to dashboards (#947)
Browse files Browse the repository at this point in the history
If we add:

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

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

    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).

    COLLECTION_FILTERS = {
      inactive: ->(resources) { resources.inactive }
    }

While the chosen hash-based syntax is a bit more verbose than simply exposing
already defined scopes like so:

    # app/dashboards/customer_dashboard.rb
    COLLECTION_FILTERS = [:inactive]

it allows us to define filters for use in Administrate without having to clutter
the resource classes with scopes.

It still allows us to add the simpler syntax in a backwards compatible way at
some point down the line if we feel the need. For example it could end up
looking like:

    COLLECTION_FILTERS = {
      vip: :vip, # This could call the method `vip` on resources
      inactive: ->(resources) { resources.where("login_at < ?", 1.week.ago) }
    }

* Allow search_spec to be run on its own,
* Introduce the concept of a search query
  - adding collection scopes/filters means we need to add more involved
    search query parsing; this gives us a place for that,
  • Loading branch information
koppen authored and nickcharlton committed Aug 16, 2019
1 parent 3c32333 commit 56ede63
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 6 deletions.
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: "vip@kind.com")
mismatch = create(:customer, kind: "standard", email: "standard@kind.com")
name_match_only = create(:customer, name: "VIP", email: "vip@name.com")

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
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
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

0 comments on commit 56ede63

Please sign in to comment.