From 56ede63a8052f1a0939a0fbe43d04cbb9a0d92b4 Mon Sep 17 00:00:00 2001 From: Jakob Skjerning Date: Fri, 16 Aug 2019 16:19:20 +0200 Subject: [PATCH] Add search filters to dashboards (#947) 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, --- docs/customizing_dashboards.md | 28 ++++++ lib/administrate/search.rb | 89 +++++++++++++++++-- .../dashboard/templates/dashboard.rb.erb | 12 +++ .../app/dashboards/customer_dashboard.rb | 4 + spec/features/search_spec.rb | 28 ++++++ spec/lib/administrate/search_query_spec.rb | 51 +++++++++++ spec/lib/administrate/search_spec.rb | 28 ++++++ 7 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 spec/lib/administrate/search_query_spec.rb diff --git a/docs/customizing_dashboards.md b/docs/customizing_dashboards.md index 72c569fa34..d1a535730e 100644 --- a/docs/customizing_dashboards.md +++ b/docs/customizing_dashboards.md @@ -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 } +} +``` diff --git a/lib/administrate/search.rb b/lib/administrate/search.rb index 77fbef2b50..5f71c51053 100644 --- a/lib/administrate/search.rb +++ b/lib/administrate/search.rb @@ -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) @@ -28,7 +87,7 @@ def query end.join(" OR ") end - def search_terms + def query_values ["%#{term.mb_chars.downcase}%"] * search_attributes.count end @@ -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 @@ -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 diff --git a/lib/generators/administrate/dashboard/templates/dashboard.rb.erb b/lib/generators/administrate/dashboard/templates/dashboard.rb.erb index 6f593dc48b..8bd26fe901 100644 --- a/lib/generators/administrate/dashboard/templates/dashboard.rb.erb +++ b/lib/generators/administrate/dashboard/templates/dashboard.rb.erb @@ -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. # diff --git a/spec/example_app/app/dashboards/customer_dashboard.rb b/spec/example_app/app/dashboards/customer_dashboard.rb index 8ecef8c7a3..9af1726e78 100644 --- a/spec/example_app/app/dashboards/customer_dashboard.rb +++ b/spec/example_app/app/dashboards/customer_dashboard.rb @@ -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 diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 7df0c10a77..549c7c637a 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -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") diff --git a/spec/lib/administrate/search_query_spec.rb b/spec/lib/administrate/search_query_spec.rb new file mode 100644 index 0000000000..2e766786c2 --- /dev/null +++ b/spec/lib/administrate/search_query_spec.rb @@ -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 diff --git a/spec/lib/administrate/search_spec.rb b/spec/lib/administrate/search_spec.rb index 87b7862096..5686be2da1 100644 --- a/spec/lib/administrate/search_spec.rb +++ b/spec/lib/administrate/search_spec.rb @@ -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" @@ -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 @@ -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