diff --git a/Gemfile.lock b/Gemfile.lock
index 7c3ef9704d..305e377197 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,7 +77,8 @@ GEM
bundler (~> 1.2)
thor (~> 0.18)
byebug (6.0.2)
- capybara (2.5.0)
+ capybara (2.7.0)
+ addressable
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
@@ -165,7 +166,7 @@ GEM
mini_portile2 (~> 2.0.0.rc2)
normalize-rails (3.0.3)
pg (0.18.3)
- poltergeist (1.7.0)
+ poltergeist (1.9.0)
capybara (~> 2.1)
cliver (~> 0.3.1)
multi_json (~> 1.0)
diff --git a/app/assets/stylesheets/administrate/components/_buttons_group.scss b/app/assets/stylesheets/administrate/components/_buttons_group.scss
new file mode 100644
index 0000000000..fced0b8c4f
--- /dev/null
+++ b/app/assets/stylesheets/administrate/components/_buttons_group.scss
@@ -0,0 +1,96 @@
+// fixme please
+.buttons-group {
+ margin: $small-spacing;
+ padding-left: $small-spacing;
+ clear: both;
+}
+
+// copied from http://refills.bourbon.io/components/#er-toc-id-5
+.button-group {
+ $base-border-color: gainsboro !default;
+ $base-border-radius: 3px !default;
+ $base-line-height: 1.5em !default;
+ $base-spacing: 1.5em !default;
+ $base-font-size: 1em !default;
+ $base-background-color: white !default;
+ $action-color: #477DCA !default;
+ $dark-gray: #333 !default;
+ $large-screen: em(860) !default;
+ $base-font-color: $dark-gray !default;
+ $button-group-background: $base-background-color;
+ $button-group-color: lighten($base-font-color, 30%);
+ $button-group-border: 1px solid silver;
+ $button-group-inner-border: 1px solid lighten(silver, 18%);
+ $button-group-background-checked: $action-color;
+ $button-group-color-checked: white;
+ $button-group-border-checked: darken($button-group-background-checked, 15%);
+
+ label {
+ margin-bottom: 0;
+
+ @include media($large-screen) {
+ float: left;
+ }
+
+ .button-group-item {
+ background: $button-group-background;
+ border-left: $button-group-border;
+ border-radius: 0;
+ border-right: $button-group-border;
+ color: $button-group-color;
+ cursor: pointer;
+ display: inline-block;
+ font-size: $base-font-size;
+ font-weight: normal;
+ line-height: 1;
+ padding: 0.75em 1em;
+ width: 100%;
+
+ @include media($large-screen) {
+ border-bottom: $button-group-border;
+ border-left: 0;
+ border-right: $button-group-inner-border;
+ border-top: $button-group-border;
+ width: auto;
+ }
+
+ &:focus,
+ &:hover {
+ background-color: darken($button-group-background, 3%);
+ }
+ }
+
+ &:first-child .button-group-item {
+ border-top-left-radius: $base-border-radius;
+ border-top-right-radius: $base-border-radius;
+ border-top: $button-group-border;
+
+ @include media($large-screen) {
+ border-bottom-left-radius: $base-border-radius;
+ border-left: $button-group-border;
+ border-top-left-radius: $base-border-radius;
+ border-top-right-radius: 0;
+ }
+ }
+
+ &:last-child .button-group-item {
+ border-bottom-left-radius: $base-border-radius;
+ border-bottom-right-radius: $base-border-radius;
+ border-bottom: $button-group-border;
+
+ @include media($large-screen) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: $base-border-radius;
+ border-right: $button-group-border;
+ border-top-right-radius: $base-border-radius;
+ }
+ }
+
+ .button-group-item.active {
+ background: $button-group-background-checked;
+ border: 1px solid $button-group-border-checked;
+ box-shadow: inset 0 1px 2px darken($button-group-background-checked, 10%);
+ color: $button-group-color-checked;
+ }
+ }
+}
diff --git a/app/controllers/administrate/application_controller.rb b/app/controllers/administrate/application_controller.rb
index a496574f40..dd7a809c89 100644
--- a/app/controllers/administrate/application_controller.rb
+++ b/app/controllers/administrate/application_controller.rb
@@ -3,15 +3,17 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
def index
- search_term = params[:search].to_s.strip
- resources = Administrate::Search.new(resource_resolver, search_term).run
+ resources = search.run
resources = order.apply(resources)
resources = resources.page(params[:page]).per(records_per_page)
- page = Administrate::Page::Collection.new(dashboard, order: order)
+ page = Administrate::Page::Collection.new(
+ dashboard,
+ search: search,
+ order: order)
render locals: {
resources: resources,
- search_term: search_term,
+ search_term: search.term,
page: page,
}
end
@@ -83,6 +85,10 @@ def records_per_page
params[:per_page] || 20
end
+ def search
+ @_search ||= Administrate::Search.new(resource_resolver, params[:search])
+ end
+
def order
@_order ||= Administrate::Order.new(params[:order], params[:direction])
end
diff --git a/app/helpers/administrate/application_helper.rb b/app/helpers/administrate/application_helper.rb
index 09df5a724a..a24822aec7 100644
--- a/app/helpers/administrate/application_helper.rb
+++ b/app/helpers/administrate/application_helper.rb
@@ -32,5 +32,30 @@ def svg_tag(asset, svg_id, options = {})
content_tag :use, nil, svg_attributes
end
end
+
+ SCOPES_LOCALE_SCOPE = [:administrate, :scopes].freeze
+ # #translated_scope(key, resource_name): Retries the translation in the
+ # root scope ('administrate.scopes') as fallback if translation for that
+ # specific model doesn't exist. For example, calling *translated_scope
+ # :active, :job_offer* with this yaml:
+ #
+ # es:
+ # scopes:
+ # active: Activos
+ # job_offer:
+ # active: Activas
+ #
+ # ...will return "Activas", but calling *translated_scope :active, :job*
+ # will return "Activos" since there's not specific translation for the
+ # job model.
+ # *NOTICE:* current code manages translation of a *scope_group* as if it
+ # were another scope, and the translations of the default group name for
+ # an array of scopes (*:scopes*) has been translated to do English (Filter)
+ # and spanish (Filtros)... collaborations welcome!
+ def translated_scope(key, resource_name)
+ I18n.t key,
+ scope: SCOPES_LOCALE_SCOPE + [resource_name],
+ default: I18n.t(key, scope: SCOPES_LOCALE_SCOPE)
+ end
end
end
diff --git a/app/views/administrate/application/_scope_buttons_group.html.erb b/app/views/administrate/application/_scope_buttons_group.html.erb
new file mode 100644
index 0000000000..ea4492927e
--- /dev/null
+++ b/app/views/administrate/application/_scope_buttons_group.html.erb
@@ -0,0 +1,18 @@
+
diff --git a/app/views/administrate/application/index.html.erb b/app/views/administrate/application/index.html.erb
index 63dc8b0651..5813d5e39d 100644
--- a/app/views/administrate/application/index.html.erb
+++ b/app/views/administrate/application/index.html.erb
@@ -55,6 +55,10 @@ It renders the `_table` partial to display details about the resources.
+<%= render partial: "scope_buttons_group",
+ collection: page.scope_groups,
+ locals: { page: page } %>
+
<%= render "collection", collection_presenter: page, resources: resources %>
<%= paginate resources %>
diff --git a/config/locales/administrate.da.yml b/config/locales/administrate.da.yml
index 0ae3a1cd08..9d244ae3e5 100644
--- a/config/locales/administrate.da.yml
+++ b/config/locales/administrate.da.yml
@@ -21,3 +21,5 @@ da:
not_supported: "Formularer med polymorphic relationships er ikke understøttede."
has_one:
not_supported: "Formularer med has_one associationer er ikke understøttede."
+ scopes:
+ scopes: Filtre
diff --git a/config/locales/administrate.de.yml b/config/locales/administrate.de.yml
index 654c5dd6dc..19dd93aa04 100644
--- a/config/locales/administrate.de.yml
+++ b/config/locales/administrate.de.yml
@@ -21,3 +21,5 @@ de:
not_supported: Polymorphe Beziehungen werden nicht unterstützt.
has_one:
not_supported: HasOne Beziehungen werden nicht unterstützt.
+ scopes:
+ scopes: Filter
diff --git a/config/locales/administrate.en.yml b/config/locales/administrate.en.yml
index 4ba8066091..2764a10800 100644
--- a/config/locales/administrate.en.yml
+++ b/config/locales/administrate.en.yml
@@ -21,3 +21,5 @@ en:
not_supported: Polymorphic relationship forms are not supported.
has_one:
not_supported: HasOne relationship forms are not supported.
+ scopes:
+ scopes: Filters
diff --git a/config/locales/administrate.es.yml b/config/locales/administrate.es.yml
index 6611123206..18ed25c849 100644
--- a/config/locales/administrate.es.yml
+++ b/config/locales/administrate.es.yml
@@ -21,3 +21,5 @@ es:
not_supported: Los formularios con relaciones polimórficas no están soportados.
has_one:
not_supported: Los formularios con relaciones HasOne no están soportados.
+ scopes:
+ scopes: Filtros
diff --git a/config/locales/administrate.fr.yml b/config/locales/administrate.fr.yml
index 0c6e510284..882d7c5ed2 100644
--- a/config/locales/administrate.fr.yml
+++ b/config/locales/administrate.fr.yml
@@ -21,3 +21,5 @@ fr:
not_supported: Les relations polymorphiques dans les formulaires ne sont pas supportées.
has_one:
not_supported: Les relations HasOne dans les formulaires ne sont pas supportées.
+ scopes:
+ scopes: Filtres
diff --git a/config/locales/administrate.it.yml b/config/locales/administrate.it.yml
index 97ca9f38df..75208f4708 100644
--- a/config/locales/administrate.it.yml
+++ b/config/locales/administrate.it.yml
@@ -21,3 +21,5 @@ it:
not_supported: Associazioni polimorfiche non ancora supportate. Spiacenti!
has_one:
not_supported: Associazioni HasOne non ancora supportate. Spiacenti!
+ scopes:
+ scopes: Filtri
diff --git a/config/locales/administrate.nl.yml b/config/locales/administrate.nl.yml
index 02da441e0e..b6b4be3e56 100644
--- a/config/locales/administrate.nl.yml
+++ b/config/locales/administrate.nl.yml
@@ -21,3 +21,5 @@ nl:
not_supported: Polymorphische relaties formulieren worden niet ondersteund.
has_one:
not_supported: HasOne relaties formulieren worden niet ondersteund.
+ scopes:
+ scopes: Filters
diff --git a/config/locales/administrate.pl.yml b/config/locales/administrate.pl.yml
index 3de3db847c..fa63485382 100644
--- a/config/locales/administrate.pl.yml
+++ b/config/locales/administrate.pl.yml
@@ -21,3 +21,5 @@ pl:
not_supported: Relacje polimorficzne nie są obsługiwane.
has_one:
not_supported: Relacje jeden-do-jednego nie są obsługiwane.
+ scopes:
+ scopes: Filtry
diff --git a/config/locales/administrate.pt-BR.yml b/config/locales/administrate.pt-BR.yml
index aa2d989c09..ff65a525bc 100644
--- a/config/locales/administrate.pt-BR.yml
+++ b/config/locales/administrate.pt-BR.yml
@@ -22,3 +22,5 @@ pt-BR:
not_supported: Relações polimórmficas nos formulários não são suportadas.
has_one:
not_supported: Relações um para muitos nos formulários não são suportadas.
+ scopes:
+ scopes: Filtros
diff --git a/config/locales/administrate.ru.yml b/config/locales/administrate.ru.yml
index ceb665128b..50e73bfcd7 100644
--- a/config/locales/administrate.ru.yml
+++ b/config/locales/administrate.ru.yml
@@ -21,3 +21,5 @@ ru:
not_supported: Полиморфные отношения в формах не поддерживаются.
has_one:
not_supported: HasOne отношения в формах не поддерживаются.
+ scopes:
+ scopes: фильтры
diff --git a/config/locales/administrate.sv.yml b/config/locales/administrate.sv.yml
index 888702111c..ebe4be4bd4 100644
--- a/config/locales/administrate.sv.yml
+++ b/config/locales/administrate.sv.yml
@@ -21,3 +21,5 @@ sv:
not_supported: Formulär med polymorfiska relationer stöds inte.
has_one:
not_supported: Formulär med HasOne relationer stöds inte.
+ scopes:
+ scopes: Filter
diff --git a/config/locales/administrate.uk.yml b/config/locales/administrate.uk.yml
index 2f37b258ff..39e9693a71 100644
--- a/config/locales/administrate.uk.yml
+++ b/config/locales/administrate.uk.yml
@@ -21,3 +21,5 @@ uk:
not_supported: Поліморфні відношення у формах не підтримуються.
has_one:
not_supported: HasOne відношення у формах не підтримуються.
+ scopes:
+ scopes: Filters
diff --git a/config/locales/administrate.vi.yml b/config/locales/administrate.vi.yml
index 015c7492a0..6ca24966c5 100644
--- a/config/locales/administrate.vi.yml
+++ b/config/locales/administrate.vi.yml
@@ -21,3 +21,5 @@ vi:
not_supported: Quan hệ Polymorphic chưa được hỗ trợ.
has_one:
not_supported: Quan hệ HasOne chưa được hỗ trợ.
+ scopes:
+ scopes: Bộ lọc
diff --git a/config/locales/administrate.zh-CN.yml b/config/locales/administrate.zh-CN.yml
index 65d540fdbf..96765aafc2 100644
--- a/config/locales/administrate.zh-CN.yml
+++ b/config/locales/administrate.zh-CN.yml
@@ -21,3 +21,5 @@ zh-CN:
not_supported: Polymorphic 关系暂不支持
has_one:
not_supported: HasOne 关系暂不支持.
+ scopes:
+ scopes: 过滤器
diff --git a/config/locales/administrate.zh-TW.yml b/config/locales/administrate.zh-TW.yml
index 8463c3052a..76f6c0d928 100644
--- a/config/locales/administrate.zh-TW.yml
+++ b/config/locales/administrate.zh-TW.yml
@@ -21,3 +21,5 @@ zh-TW:
not_supported: 表單尚未支援 Polymorphic 關聯。
has_one:
not_supported: 表單尚未支援 HasOne 關聯。
+ scopes:
+ scopes: 過濾器
diff --git a/lib/administrate/base_dashboard.rb b/lib/administrate/base_dashboard.rb
index 83e993aa11..e9b6e7a55c 100644
--- a/lib/administrate/base_dashboard.rb
+++ b/lib/administrate/base_dashboard.rb
@@ -49,6 +49,14 @@ def collection_attributes
self.class::COLLECTION_ATTRIBUTES
end
+ def collection_scopes
+ if self.class.const_defined?(:COLLECTION_SCOPES)
+ self.class::COLLECTION_SCOPES
+ else
+ []
+ end
+ end
+
def display_resource(resource)
"#{resource.class} ##{resource.id}"
end
diff --git a/lib/administrate/page/collection.rb b/lib/administrate/page/collection.rb
index 1462faa1a9..113b9e0836 100644
--- a/lib/administrate/page/collection.rb
+++ b/lib/administrate/page/collection.rb
@@ -21,6 +21,77 @@ def ordered_html_class(attr)
ordered_by?(attr) && order.direction
end
+ def scope_groups
+ if dashboard.collection_scopes.is_a?(Hash)
+ dashboard.collection_scopes.keys
+ else
+ dashboard.collection_scopes.any? ? [:scopes] : []
+ end
+ end
+
+ def scope_names(group = nil)
+ if dashboard.collection_scopes.is_a?(Hash)
+ group ||= dashboard.collection_scopes.keys.first
+ dashboard.collection_scopes[group].map &:to_s
+ else
+ dashboard.collection_scopes.map &:to_s
+ end.reject do |scope|
+ # do NOT show the wildcarded scopes
+ scope[-2..-1] == ":*"
+ end
+ end
+
+ def search
+ @options[:search]
+ end
+
+ def scoped_with?(scope)
+ search.term.include? "scope:#{scope}"
+ end
+
+ # #scope_group(scope) receives an scope declared in the dashboard's
+ # collection_scopes and returns the group of the array in which is found.
+ def scope_group(scope)
+ scope_groups.detect do |group|
+ scope_names(group).include?(scope.to_s)
+ end
+ end
+
+ # #scoped_groups returns an array with the COLLECTION_SCOPES' keys (i.e.
+ # group name) which array contains a scope that is used in the current
+ # search.
+ def scoped_groups
+ search.scopes_with_arguments.map {|s| scope_group(s)}
+ end
+
+ # #current_scope_of(group) receives a key (*group*) of the
+ # collection_scopes hash (i.e. COLLECTION_SCOPES) and returns the scope
+ # used in the current search that is into its array, or nil if none.
+ def current_scope_of(group)
+ search.scopes_with_arguments.detect {|s| scope_group(s) == group}
+ end
+
+ # #term_with_scope(scope) receives an scope and adds it to the current
+ # search avoiding duplication and collision with another scope of the
+ # same group (assuming that together will give no results).
+ def term_with_scope(scope)
+ if scoped_with?(scope)
+ search.term
+ else
+ group = scope_group(scope)
+ if scoped_groups.include? group
+ search.term.sub "scope:#{current_scope_of(group)}", "scope:#{scope}"
+ else
+ "#{search.term} scope:#{scope}".strip
+ end
+ end
+ end
+
+ # #term_without_scope(scope) removes the scope from the search term.
+ def term_without_scope(scope)
+ search.term.sub("scope:#{scope}", "").strip
+ end
+
delegate :ordered_by?, :order_params_for, to: :order
private
diff --git a/lib/administrate/search.rb b/lib/administrate/search.rb
index e5d823de06..611cdd186b 100644
--- a/lib/administrate/search.rb
+++ b/lib/administrate/search.rb
@@ -3,29 +3,68 @@
module Administrate
class Search
+ # Only used if dashboard's COLLECTION_SCOPES is not defined
+ BLACKLISTED_WORDS = %w{destroy remove delete update create}.freeze
+
+ attr_reader :resolver, :term, :words
+
def initialize(resolver, term)
+ @term = term.to_s.strip
@resolver = resolver
- @term = term
+ @words, @scopes = words_and_scopes_of(@term ? @term.split : [])
+ end
+
+ def scopes
+ @scopes.map(&:name)
+ end
+
+ def arguments
+ @scopes.map(&:argument)
+ end
+
+ def scopes_with_arguments
+ @scopes.map(&:user_input)
+ end
+
+ def scope
+ scopes.first
end
def run
if @term.blank?
resource_class.all
else
- resource_class.where(query, *search_terms)
+ resources = if @words.empty?
+ resource_class.all
+ else
+ resource_class.where(query, *search_terms)
+ end
+ filter_with_scopes(resources)
end
end
private
delegate :resource_class, to: :resolver
+ delegate :dashboard_class, to: :resolver
+
+ def filter_with_scopes(resources)
+ @scopes.each do |scope|
+ resources = if scope.argument
+ resources.public_send scope.name, scope.argument
+ else
+ resources.public_send scope.name
+ end
+ end
+ resources
+ end
def query
search_attributes.map { |attr| "lower(#{attr}) LIKE ?" }.join(" OR ")
end
def search_terms
- ["%#{term.downcase}%"] * search_attributes.count
+ ["%#{words.join.downcase}%"] * search_attributes.count
end
def search_attributes
@@ -38,6 +77,91 @@ def attribute_types
resolver.dashboard_class::ATTRIBUTE_TYPES
end
- attr_reader :resolver, :term
+ # Extracts the possible scope from *term* (a single word string) and
+ # returns it if the model responds to it and is a valid_scope?
+ def scope_object(term)
+ if term && (/(?\w+):(?.+)/i =~ term)
+ obj = build_scope_ostruct(left_part, right_part)
+ obj if resource_class.respond_to?(obj.name) && valid_scope?(obj)
+ end
+ end
+
+ def build_scope_ostruct(left_part, right_part)
+ if left_part.casecmp("scope") == 0
+ user_input = right_part
+ if /(?\w+)\((?\w+)\)/ =~ right_part
+ name = scope_name
+ argument = scope_argument
+ else
+ name = user_input
+ argument = nil
+ end
+ else
+ user_input = "#{left_part}:#{right_part}"
+ name = left_part
+ argument = right_part
+ end
+ OpenStruct.new(user_input: user_input, name: name, argument: argument)
+ end
+
+ # If the COLLECTION_SCOPES is not empty returns true if the possible_scope
+ # is included in it (i.e. whitelisted), and returns false if is empty.
+ # If COLLECTION_SCOPES isn't defined returns true if it's not blacklisted
+ # nor ending with an exclamation mark.
+ def valid_scope?(scope_obj)
+ if collection_scopes.any?
+ collection_scopes_include?(scope_obj.user_input) ||
+ wildcarded_scope?(scope_obj.name)
+ elsif dashboard_class.const_defined?(:COLLECTION_SCOPES)
+ false
+ else
+ !banged?(scope_obj.user_input) &&
+ !blacklisted_scope?(scope_obj.user_input)
+ end
+ end
+
+ def collection_scopes_include?(s)
+ collection_scopes.include?(s) || collection_scopes.include?(s.to_sym)
+ end
+
+ def wildcarded_scope?(scope)
+ collection_scopes.include?("#{scope}:*")
+ end
+
+ def banged?(method)
+ method[-1, 1] == "!"
+ end
+
+ def blacklisted_scope?(scope)
+ BLACKLISTED_WORDS.each do |word|
+ return true if scope =~ /.*#{word}.*/i
+ end
+ false
+ end
+
+ def collection_scopes
+ @_scopes ||= if dashboard_class.const_defined?(:COLLECTION_SCOPES)
+ const = dashboard_class.const_get(:COLLECTION_SCOPES)
+ const.is_a?(Array) ? const : const.values.flatten
+ else
+ []
+ end
+ end
+
+ # Recursive function that takes a splited search string (term) as input and
+ # returns an array with two arrays: the first with the ordinary words and
+ # the other with the scopes.
+ def words_and_scopes_of(terms, words = [], scopes = [])
+ if terms.any?
+ first_term = terms.shift
+ if scope_obj = scope_object(first_term)
+ words_and_scopes_of terms, words, scopes.push(scope_obj)
+ else
+ words_and_scopes_of terms, words.push(first_term), scopes
+ end
+ else
+ [words, scopes]
+ end
+ end
end
end
diff --git a/lib/generators/administrate/dashboard/templates/dashboard.rb.erb b/lib/generators/administrate/dashboard/templates/dashboard.rb.erb
index 7ca22b803a..d721154ed7 100644
--- a/lib/generators/administrate/dashboard/templates/dashboard.rb.erb
+++ b/lib/generators/administrate/dashboard/templates/dashboard.rb.erb
@@ -53,4 +53,62 @@ class <%= class_name %>Dashboard < Administrate::BaseDashboard
# def display_resource(<%= file_name %>)
# "<%= class_name %> ##{<%= file_name %>.id}"
# end
+
+ # COLLECTION_SCOPES
+ # an array or hash that define the valid scopes that could be used while
+ # serching as part of the query string.
+ COLLECTION_SCOPES = [] # Comment to use any scope, but read this text below.
+ # If the above COLLECTION_SCOPES definition doesn't exist then any "scope"
+ # defined could be used searching *scope:*. Though this
+ # could be a nice feature in applications that has the dashboard access
+ # properly secured **this approach is not recommended**. Administrate has
+ # no way to know the scopes defined in the model and will send to the model
+ # anything not included in its blacklist.
+ #
+ # When defined buttons will appear in the index header in order to filter the
+ # resources displayed. If it's an array it will be treated internally as if
+ # it were a hash with a single key called *scopes* pointing to our array. The
+ # hash's keys and the scope definitions will be used to show a localized
+ # caption for each button using *administrate.scopes.* as I18n's
+ # scope. If no translation the scope in that model, Administrate'll retry the
+ # translation with the scope *administrate.scopes*. That will let us share
+ # the same translation between different models (and be DRY!).
+ #
+ # Definition example with an Arrray:
+ #
+ # COLLECTION_SCOPES = [
+ # :opened,
+ # :closed
+ # ]
+ #
+ # Definition example with a Hash:
+ #
+ # COLLECTION_SCOPES = {
+ # status: [:opened, :closed],
+ # headquarters: [:madrid, :oviedo, :mexicodf]
+ # }
+ #
+ # Scopes with an argument can also be defined. An explicit value for the
+ # argument can be defined adding that value after scope name between
+ # parenthesis and without quotes. For example:
+ #
+ # COLLECTION_SCOPES = {
+ # headquarter: ["office(madrid)", "office(oviedo)", "office(mexicodf)"]
+ # }
+ #
+ # Will use the scope *office(city)* using "madrid", "oviedo" and "mexicodf"
+ # as arguments.
+ #
+ # Finally, it's possible to let the user indicate the value of the argument
+ # as part of the search query adding ":*" after the scope name. For example:
+ #
+ # COLLECTION_SCOPES = {
+ # headquarter: ["office(madrid)", "office(oviedo)", "office:*"]
+ # }
+ #
+ # Won't show any scope button for "office:*" but will let us indicate any
+ # value after "office:" to use it as argument for the *office(city)* scope.
+ # If our search query is "office:mexicodf" we'll get the same results than
+ # clicking in the third button of the previous example (which query would be
+ # "scope:office(mexicodf)").
end
diff --git a/spec/example_app/app/dashboards/customer_dashboard.rb b/spec/example_app/app/dashboards/customer_dashboard.rb
index f24824f41a..7d6ab5550c 100644
--- a/spec/example_app/app/dashboards/customer_dashboard.rb
+++ b/spec/example_app/app/dashboards/customer_dashboard.rb
@@ -21,6 +21,13 @@ class CustomerDashboard < Administrate::BaseDashboard
:kind,
].freeze
+ COLLECTION_SCOPES = [
+ :subscribed,
+ :old,
+ "name_starts_with(A)",
+ "name_starts_with:*",
+ ].freeze
+
def display_resource(customer)
customer.name
end
diff --git a/spec/example_app/app/dashboards/order_dashboard.rb b/spec/example_app/app/dashboards/order_dashboard.rb
index bbc61b0dd7..ba77ccc2da 100644
--- a/spec/example_app/app/dashboards/order_dashboard.rb
+++ b/spec/example_app/app/dashboards/order_dashboard.rb
@@ -32,6 +32,11 @@ class OrderDashboard < Administrate::BaseDashboard
:shipped_at,
]
+ COLLECTION_SCOPES = {
+ status: [:shipped, :unshipped],
+ content: ["zip_prefix(00000)"],
+ }
+
FORM_ATTRIBUTES = ATTRIBUTE_TYPES.keys - READ_ONLY_ATTRIBUTES
SHOW_PAGE_ATTRIBUTES = ATTRIBUTE_TYPES.keys
end
diff --git a/spec/example_app/app/models/customer.rb b/spec/example_app/app/models/customer.rb
index a780e67da6..9adf7d2740 100644
--- a/spec/example_app/app/models/customer.rb
+++ b/spec/example_app/app/models/customer.rb
@@ -4,6 +4,12 @@ class Customer < ActiveRecord::Base
validates :name, presence: true
validates :email, presence: true
+ scope :subscribed, -> { where(email_subscriber: true) }
+ scope :old, -> { where("created_at < ?", 3.years.ago) }
+ scope :name_starts_with, ->(beginning) do
+ where("name LIKE ?", "#{beginning}%")
+ end
+
KINDS = [
:standard,
:vip,
diff --git a/spec/example_app/app/models/order.rb b/spec/example_app/app/models/order.rb
index 327db20923..1107ce56ec 100644
--- a/spec/example_app/app/models/order.rb
+++ b/spec/example_app/app/models/order.rb
@@ -10,6 +10,10 @@ class Order < ActiveRecord::Base
validates :address_state, presence: true
validates :address_zip, presence: true
+ scope :unshipped, -> { where shipped_at: nil }
+ scope :shipped, -> { where.not shipped_at: nil }
+ scope :zip_prefix, ->(prefix) { where("address_zip LIKE ?", "#{prefix}%") }
+
def total_price
line_items.map(&:total_price).reduce(0, :+)
end
diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb
index b6060c50fb..4320c9c289 100644
--- a/spec/features/search_spec.rb
+++ b/spec/features/search_spec.rb
@@ -30,4 +30,177 @@
expect(page).to have_content(email_match.email)
expect(page).not_to have_content(mismatch.email)
end
+
+ scenario "admin searches using a model scope", :js do
+ query = "scope:subscribed"
+ subscribed_customer = create(
+ :customer,
+ name: "Dan Croak",
+ email_subscriber: true)
+ other_customer = create(
+ :customer,
+ name: "Foo Bar",
+ email_subscriber: false)
+
+ visit admin_customers_path
+ fill_in :search, with: query
+ page.execute_script("$('.search').submit()")
+
+ expect(page).to have_content(subscribed_customer.name)
+ expect(page).not_to have_content(other_customer.name)
+ end
+
+ scenario "ignores malicious scope searches", :js do
+ query = "scope:destroy_all"
+ customer = create(
+ :customer,
+ name: "FooBar destroy_all: user",
+ email_subscriber: false)
+
+ visit admin_customers_path
+ fill_in :search, with: query
+ page.execute_script("$('.search').submit()")
+ expect(page).to have_content(customer.name)
+ end
+
+ scenario "admin searches a word into a model scope", :js do
+ searching_for = "Lua"
+ query = "scope:subscribed #{searching_for}"
+ subscribed_unmathed = create(
+ :customer,
+ name: "Dan Croak",
+ email_subscriber: true)
+ subscribed_matched = create(
+ :customer,
+ name: "#{searching_for} Miaus",
+ email_subscriber: true)
+ unsubscribed_matched = create(
+ :customer,
+ name: "#{searching_for} Doe",
+ email_subscriber: false)
+
+ visit admin_customers_path
+ fill_in :search, with: query
+ page.execute_script("$('.search').submit()")
+
+ page.within("tr.table__row", match: :first) do
+ expect(page).to have_content(subscribed_matched.name)
+ end
+ expect(page).not_to have_content(subscribed_unmathed.name)
+ expect(page).not_to have_content(unsubscribed_matched.name)
+ end
+
+ scenario "admin searches a word inside two model scopes", :js do
+ searching_for = "Lua"
+ query = "scope:subscribed scope:old #{searching_for}"
+ subscribed_unmathed = create(
+ :customer,
+ name: "Dan Croak",
+ email_subscriber: true)
+ unsubscribed_matched = create(
+ :customer,
+ name: "#{searching_for} Doe",
+ email_subscriber: false)
+ subscribed_match_but_new = create(
+ :customer,
+ created_at: 1.day.ago,
+ name: "#{searching_for} New",
+ email_subscriber: true)
+ subscribed_and_old_match = create(
+ :customer,
+ created_at: 5.years.ago,
+ name: "#{searching_for} Miaus",
+ email_subscriber: true)
+
+ visit admin_customers_path
+ fill_in :search, with: query
+ page.execute_script("$('.search').submit()")
+
+ page.within("tr.table__row", match: :first) do
+ expect(page).to have_content(subscribed_and_old_match.name)
+ end
+ expect(page).not_to have_content(subscribed_unmathed.name)
+ expect(page).not_to have_content(unsubscribed_matched.name)
+ expect(page).not_to have_content(subscribed_match_but_new.name)
+ end
+
+ scenario "admin clicks a scope button defined in an array", :js do
+ subscribed = create(
+ :customer,
+ name: "Lua Miaus",
+ email_subscriber: true)
+ unsubscribed = create(
+ :customer,
+ name: "John Doe",
+ email_subscriber: false)
+
+ visit admin_customers_path
+
+ # Included into the COLLECTION_SCOPES array of the CustomersDashboard
+ click_on "subscribed"
+
+ page.within("tr.table__row", match: :first) do
+ expect(page).to have_content(subscribed.name)
+ end
+ expect(page).not_to have_content(unsubscribed.name)
+ end
+
+ scenario "admin clicks a scope button defined in a hash", :js do
+ address_zip = "00000-9999"
+ searched = create(:order, address_zip: address_zip)
+ # other = create(:order) # not used, explained below
+ create(:order)
+
+ visit admin_orders_path
+
+ # Included into the COLLECTION_SCOPES hash of the OrdersDashboard
+ click_on "zip_prefix(00000)"
+
+ page.within("tr.table__row", match: :first) do
+ expect(page).to have_content(searched.id)
+ expect(page).to have_content(searched.customer.name)
+ end
+ # *id* is not a valid field to check exclusion from results :(
+ # expect(page).not_to have_content(other.id)
+ end
+
+ scenario "admin searches using a model scope w/ an argument", :js do
+ query = "scope:name_starts_with(L)"
+ match = create(
+ :customer,
+ name: "Lua Miaus")
+ unmatch = create(
+ :customer,
+ name: "John Doe")
+
+ visit admin_customers_path
+ fill_in :search, with: query
+ page.execute_script("$('.search').submit()")
+
+ page.within("tr.table__row", match: :first) do
+ expect(page).to have_content(match.name)
+ end
+ expect(page).not_to have_content(unmatch.name)
+ end
+
+ scenario "admin searches using a 'wildcarded' scope", :js do
+ query = "name_starts_with:ZZ"
+ match = create(
+ :customer,
+ name: "ZZTop")
+ unmatch = create(
+ :customer,
+ name: "John Doe")
+
+ visit admin_customers_path
+ fill_in :search, with: query
+ page.execute_script("$('.search').submit()")
+ page.within("tr.table__row", match: :first) do
+ expect(page).to have_content(match.name)
+ end
+ expect(page).not_to have_content(unmatch.name)
+
+ # ...and the wildcarded scope doesn't have its button to be clicked.
+ expect(page).not_to have_content("name_starts_with:*")
+ end
end
diff --git a/spec/lib/administrate/page/collection_spec.rb b/spec/lib/administrate/page/collection_spec.rb
new file mode 100644
index 0000000000..f7e18ea2ff
--- /dev/null
+++ b/spec/lib/administrate/page/collection_spec.rb
@@ -0,0 +1,420 @@
+require "spec_helper"
+require "support/constant_helpers"
+require "administrate/page/collection"
+require "administrate/search"
+
+describe Administrate::Page::Collection do
+ # Constants defined in spec_helper.rb
+ let(:scopes_array) { DashboardWithAnArrayOfScopes::COLLECTION_SCOPES }
+ let(:scopes_hash) { DashboardWithAHashOfScopes::COLLECTION_SCOPES }
+ let(:dashboard_wo_scopes) { MockDashboard.new }
+ let(:dashboard_w_scopes_array) { DashboardWithAnArrayOfScopes.new }
+ let(:dashboard_w_scopes_hash) { DashboardWithAHashOfScopes.new }
+
+ # #scope_groups creates the concept of "group of scopes" to manage scopes
+ # always grouped reading Dashboard#collection_scopes (COLLECTION_SCOPES).
+ describe "#scope_groups" do
+ describe "with no scopes defined" do
+ it "returns an empty array" do
+ page = Administrate::Page::Collection.new(dashboard_wo_scopes)
+ expect(page.scope_groups).to eq([])
+ end
+ end
+
+ describe "with an Array of scopes" do
+ it "returns an array with the :scopes symbol inside ([:scopes])" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array)
+ expect(page.scope_groups).to eq([:scopes])
+ end
+ end
+
+ describe "with a Hash grouping the scopes" do
+ it "returns the hash's keys" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash)
+ expect(page.scope_groups).to eq(scopes_hash.keys)
+ end
+ end
+ end
+
+ # #scope_names([group]) returns an array with *scope names*. A *scope name*
+ # can be a symbol or a string that matchs with an scope defined in the
+ # dashboard's model including the scope's argument when needed (e.g.
+ # ["valid", "awesome_since(2004)"]).
+ describe "#scope_names([group])" do
+ describe "with no scopes defined" do
+ it "returns an empty array (and ignores group passed as argument)" do
+ page = Administrate::Page::Collection.new(dashboard_wo_scopes)
+ expect(page.scope_names).to eq([])
+ expect(page.scope_names(:scopes)).to eq([])
+ end
+ end
+
+ describe "with an Array of scopes" do
+ let(:array_of_strings) { scopes_array.map(&:to_s) }
+
+ it "returns that array (and ignores group passed as argument)" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array)
+ expect(page.scope_names).to eq(array_of_strings)
+ expect(page.scope_names(:scopes)).to eq(array_of_strings)
+ end
+ end
+
+ describe "with a Hash grouping the scopes" do
+ let(:scopes_strigified_without_wildcarded) do
+ scopes_hash[:status].map(&:to_s).reject { |s| s[-2..-1] == ":*" }
+ end
+
+ it "returns the stringified scopes of the group rejecting wildcarded" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash)
+ expect(page.scope_names(:status)).
+ to eq(scopes_strigified_without_wildcarded)
+ end
+ end
+ end
+
+ # #scope_group(scope) receives an scope declared in the dashboard's
+ # collection_scopes and returns the group of the array in which is found.
+ describe "#scope_group(scope)" do
+ describe "with no scopes defined" do
+ it "returns nil" do
+ page = Administrate::Page::Collection.new(dashboard_wo_scopes)
+ expect(page.scope_group(:anything)).to eq(nil)
+ end
+ end
+
+ describe "with an Array of scopes returns the default key (:scopes)" do
+ let(:string_scope) { scopes_array.detect{|s| s.is_a? String } }
+ let(:symbol_scope) { scopes_array.detect{|s| s.is_a? Symbol } }
+
+ it "if the scope is into that array" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array)
+ expect(page.scope_group(string_scope)).to eq(:scopes)
+ end
+
+ it "if the param is a symbol and the scope is defined with a string" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array)
+ expect(page.scope_group(string_scope)).to eq(:scopes)
+ end
+
+ it "if the param is a string and the scope is defined with a symbol" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array)
+ expect(page.scope_group(symbol_scope)).to eq(:scopes)
+ end
+
+ it "returns nil if the scope is not defined in the array" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array)
+ expect(page.scope_group("nonexistent")).to eq(nil)
+ end
+ end
+ end
+
+ # #scoped_groups returns an array with the COLLECTION_SCOPES' keys (i.e.
+ # group name) which array contains a scope that is used in the current
+ # search.
+ describe "#scoped_groups" do
+ let(:search) { Administrate::Search.new(nil, "searched words") }
+
+ describe "with no scopes defined" do
+ it "returns an empty array" do
+ page = Administrate::Page::Collection.new(dashboard_wo_scopes,
+ search: search)
+ expect(page.scoped_groups).to eq([])
+ end
+ end
+
+ describe "with an Array of scopes" do
+ it "returns an empty array if the query has no scopes" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array,
+ search: search)
+ expect(page.scoped_groups).to eq([])
+ end
+
+ it "returns an array with the default key (:scopes) if the query has a valid scope" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array,
+ search: search_with_scope)
+ expect(page.scoped_groups).to eq([:scopes])
+ ensure
+ remove_constants :User
+ end
+ end
+
+ # same as above, but with "new" instead of "old" which is not valid
+ it "returns an empty array if that array doesn't include the scope" do
+ begin
+ class User
+ def self.new; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAHashOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:new")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array,
+ search: search_with_scope)
+ expect(page.scoped_groups).to eq([])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+
+ describe "with a Hash of scopes" do
+ it "returns an empty array if the query has no scopes" do
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash,
+ search: search)
+ expect(page.scoped_groups).to eq([])
+ end
+
+ it "returns an array with the key of the array that includes the scope" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAHashOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash,
+ search: search_with_scope)
+ expect(page.scoped_groups).to eq([:other])
+ ensure
+ remove_constants :User
+ end
+ end
+
+ # same as above, but with "new" instead of "old" which is not valid
+ it "returns an empty array if the scope isn't included in any array" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAHashOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:new")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash,
+ search: search_with_scope)
+ expect(page.scoped_groups).to eq([])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+ end
+
+ # #current_scope_of(group) receives a key (*group*) of the
+ # collection_scopes hash (i.e. COLLECTION_SCOPES) and returns the scope
+ # used in the current search that is into its array, or nil if none.
+ describe "#current_scope_of(group)" do
+ let(:search) { Administrate::Search.new(nil, "searched words") }
+
+ describe "with no scopes defined" do
+ it "returns nil" do
+ page = Administrate::Page::Collection.new(dashboard_wo_scopes,
+ search: search)
+ expect(page.current_scope_of(:scopes)).to eq(nil)
+ end
+ end
+
+ describe "with an Array of scopes" do
+ # scopes will be grouped with the :scopes key in Search#collection_scopes
+ it "returns the scope if is into the specified group (:scopes)" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array,
+ search: search_with_scope)
+ expect(page.current_scope_of(:scopes)).to eq("old")
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "returns nil if the scope isn't into the specified group (:scopes)" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:new")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array,
+ search: search_with_scope)
+ expect(page.current_scope_of(:scopes)).to eq(nil)
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+
+ describe "with a Hash of scopes" do
+ it "returns the scope if is into the specified group" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash,
+ search: search_with_scope)
+ # the :old scope is defined in the :other group (see spec_helper.rb).
+ expect(page.current_scope_of(:other)).to eq("old")
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "returns nil if is into another group" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search_with_scope = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash,
+ search: search_with_scope)
+ # the :old scope is defined in the :other group (see spec_helper.rb).
+ expect(page.current_scope_of(:status)).to eq(nil)
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+ end
+
+ # #term_with_scope(scope) receives an scope and adds it to the current
+ # search avoiding duplication and collision with another scope of the
+ # same group (assuming that together will give no results).
+ describe "#term_with_scope(scope)" do
+ describe "with no scopes defined" do
+ it "returns the term with the scope" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search = Administrate::Search.new(resolver, "")
+
+ page = Administrate::Page::Collection.new(dashboard_wo_scopes,
+ search: search)
+ expect(page.term_with_scope("old")).to eq("scope:old")
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "doesn't duplicate the scope if its already" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_wo_scopes,
+ search: search)
+ expect(page.term_with_scope("old")).to eq("scope:old")
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+
+ describe "with an array of scopes defined" do
+ it "replaces the current scope with the new one" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array,
+ search: search)
+ expect(page.term_with_scope("active")).to eq("scope:active")
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+
+ describe "with a hash of scopes" do
+ it "replaces the current scope with the new one" do
+ begin
+ class User
+ def self.active; end
+
+ def self.inactive; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAHashOfScopes)
+ search = Administrate::Search.new(resolver, "scope:inactive")
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash,
+ search: search)
+ expect(page.term_with_scope("active")).to eq("scope:active")
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "adds the scope if is included in other group" do
+ begin
+ class User
+ def self.old; end
+
+ def self.active; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAHashOfScopes)
+ search = Administrate::Search.new(resolver, "scope:old")
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_hash,
+ search: search)
+ expect(page.term_with_scope("active")).to eq("scope:old scope:active")
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+ end
+
+ # #term_without_scope(scope) removes the *scope* from the current search
+ # term if present
+ describe "#term_without_scope(scope)" do
+ it "returns the term without the scope" do
+ begin
+ class User
+ def self.old; end
+ end
+ resolver = double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ search = Administrate::Search.new(resolver, "scope:old")
+
+ page = Administrate::Page::Collection.new(dashboard_w_scopes_array,
+ search: search)
+ expect(page.term_without_scope("undefined")).to eq("scope:old")
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+end
diff --git a/spec/lib/administrate/search_spec.rb b/spec/lib/administrate/search_spec.rb
index 94c77ffed0..6597b24623 100644
--- a/spec/lib/administrate/search_spec.rb
+++ b/spec/lib/administrate/search_spec.rb
@@ -5,15 +5,25 @@
require "administrate/field/number"
require "administrate/search"
-class MockDashboard
- ATTRIBUTE_TYPES = {
- name: Administrate::Field::String,
- email: Administrate::Field::Email,
- phone: Administrate::Field::Number,
- }
-end
+# NOTE: Dashboard mocks (MockDashboard, DashboardWithAnArrayOfScopes and
+# DashboardWithAHashOfScopes) are defined in spec/spec_helper.rb.
describe Administrate::Search do
+ describe "#new(resolver, query)" do
+ let(:symbol) { :amazing }
+ let(:between_whitespaces) { " \t\v #{symbol}\f \r\n" }
+
+ it "accepts the query as a symbol" do
+ search = Administrate::Search.new(nil, symbol)
+ expect(search.term).to eq(symbol.to_s)
+ end
+
+ it "removes whitespaces from the query" do
+ search = Administrate::Search.new(nil, between_whitespaces)
+ expect(search.term).to eq(symbol.to_s)
+ end
+ end
+
describe "#run" do
it "returns all records when no search term" do
begin
@@ -59,4 +69,353 @@ class User; end
end
end
end
+
+ describe "#scopes (and #scope as #scopes.first)" do
+ let(:scope) { "active" }
+ let(:resolver) do
+ double(resource_class: User, dashboard_class: MockDashboard)
+ end
+
+ describe "the query is one scope" do
+ let(:query) { "scope:#{scope}" }
+ let(:scopes_disabled_resolver) do
+ double(resource_class: User,
+ dashboard_class: DashboardWithScopesDisabled)
+ end
+
+ it "returns nil if the model does not respond to the possible scope" do
+ begin
+ class User; end
+ search = Administrate::Search.new(resolver, query)
+ expect(search.scope).to eq(nil)
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "returns the scope if the model responds to it" do
+ begin
+ class User
+ def self.active; end
+ end
+ search = Administrate::Search.new(resolver, query)
+ expect(search.scope).to eq(scope)
+ ensure
+ remove_constants :User
+ end
+ end
+
+ # DashboardWithScopesDisabled define COLLECTION_SCOPES as an empty array.
+ it "returns nil if the dashboard's search into scopes is disabled" do
+ begin
+ class User
+ def self.active; end
+ end
+ search = Administrate::Search.new(scopes_disabled_resolver, query)
+ expect(search.scope).to eq(nil)
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "ignores the case of the 'scope:' prefix" do
+ begin
+ class User
+ def self.active; end
+ end
+ search = Administrate::Search.new(resolver, "ScoPE:#{scope}")
+ expect(search.scope).to eq(scope)
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "returns nil if the name of the scope looks suspicious" do
+ begin
+ class User
+ def self.destroy_all; end
+ end
+
+ Administrate::Search::BLACKLISTED_WORDS.each do |word|
+ search = Administrate::Search.new(resolver, "scope:#{word}_all")
+ expect(search.scope).to eq(nil)
+ end
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "returns nil if the name of the scope ends with an exclamation mark" do
+ begin
+ class User
+ def self.bang!; end
+ end
+
+ search = Administrate::Search.new(resolver, "scope:bang!")
+ expect(search.scope).to eq(nil)
+ ensure
+ remove_constants :User
+ end
+ end
+
+ describe "with COLLECTION_SCOPES defined as an array" do
+ let(:resolver) do
+ double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ end
+
+ it "ignores the scope if it isn't included in COLLECTION_SCOPES" do
+ begin
+ class User
+ def self.closed; end
+ end
+ search = Administrate::Search.new(resolver, "scope:closed")
+ expect(search.scope).to eq(nil)
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "returns the scope if it's included into COLLECION_SCOPES" do
+ begin
+ class User
+ def self.active; end
+ end
+ search = Administrate::Search.new(resolver, "scope:active")
+ expect(search.scope).to eq("active")
+ ensure
+ remove_constants :User
+ end
+ end
+
+ # The following should match with what is declared by COLLECTION_SCOPES
+ # up within the DashboardWithAnArrayOfScopes class.
+ let(:scope) { "with_argument" }
+ let(:argument) { "3" }
+ let(:scope_with_argument) { "#{scope}(#{argument})" }
+ it "returns the scope even if its key has an argument" do
+ begin
+ class User
+ def self.with_argument(argument); argument; end
+ end
+ search = Administrate::Search.new(resolver,
+ "scope:#{scope_with_argument}")
+ expect(search.scope).to eq(scope)
+ expect(search.scopes).to eq([scope])
+ expect(search.arguments).to eq([argument])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+
+ # Folloing are the same previous specs using a Hash instead of an array.
+ describe "with COLLECTION_SCOPES defined as a hash of arrays w/ scopes" do
+ let(:resolver) do
+ double(resource_class: User,
+ dashboard_class: DashboardWithAHashOfScopes)
+ end
+
+ it "ignores the scope if it isn't included in COLLECTION_SCOPES keys" do
+ begin
+ class User
+ def self.closed; end
+ end
+ search = Administrate::Search.new(resolver, "scope:closed")
+ expect(search.scope).to eq(nil)
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "returns the scope if it's included into COLLECION_SCOPES keys" do
+ begin
+ class User
+ def self.active; end
+ end
+ search = Administrate::Search.new(resolver, "scope:active")
+ expect(search.scope).to eq("active")
+ ensure
+ remove_constants :User
+ end
+ end
+
+ # The following should match with what is declared by COLLECTION_SCOPES
+ # up within the DashboardWithAHashOfScopes class.
+ let(:scope) { "with_argument" }
+ let(:argument) { "3" }
+ let(:scope_with_argument) { "#{scope}(#{argument})" }
+ it "returns the scope even if its key has an argument" do
+ begin
+ class User
+ def self.with_argument(argument); argument; end
+ end
+ search = Administrate::Search.new(resolver,
+ "scope:#{scope_with_argument}")
+ expect(search.scope).to eq(scope)
+ expect(search.scopes).to eq([scope])
+ expect(search.arguments).to eq([argument])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+ end
+
+ describe "the query is a word and a scope" do
+ let(:word) { "foobar" }
+
+ it "returns the scope and #words the word" do
+ begin
+ class User
+ def self.active; end
+ end
+
+ search = Administrate::Search.new(resolver, "scope:#{scope} #{word}")
+ expect(search.scope).to eq(scope)
+ expect(search.words).to eq([word])
+ ensure
+ remove_constants :User
+ end
+ end
+
+ it "ignores the order" do
+ begin
+ class User
+ def self.active; end
+ end
+
+ search = Administrate::Search.new(resolver, "#{word} scope:#{scope}")
+ expect(search.scope).to eq(scope)
+ expect(search.words).to eq([word])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+
+ describe "the query is a word and two scopes" do
+ let(:word) { "foobar" }
+ let(:other_scope) { "subscribed" }
+
+ describe "in that order" do
+ let(:query) { "#{word} scope:#{scope} scope:#{other_scope}" }
+
+ it "returns the scopes and #words the word" do
+ begin
+ class User
+ def self.active; end
+
+ def self.subscribed; end
+ end
+
+ search = Administrate::Search.new(resolver, query)
+ expect(search.scopes).to eq([scope, other_scope])
+ expect(search.words).to eq([word])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+
+ describe "with the word between the two scopes" do
+ let(:query) { "scope:#{scope} #{word} scope:#{other_scope}" }
+
+ it "returns the scopes and #words the word" do
+ begin
+ class User
+ def self.active; end
+
+ def self.subscribed; end
+ end
+ search = Administrate::Search.new(resolver, query)
+
+ expect(search.scopes).to eq([scope, other_scope])
+ expect(search.words).to eq([word])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+ end
+
+ describe "the query is one scope with an argument" do
+ let(:scope) { "name_starts_with" }
+ let(:argument) { "A" }
+ let(:query) { "scope:#{scope}(#{argument})" }
+
+ it "returns the [scope] and #arguments the [argument]" do
+ begin
+ class User
+ def self.name_starts_with(_letter); end
+ end
+ search = Administrate::Search.new(resolver, query)
+ expect(search.scopes).to eq([scope])
+ expect(search.arguments).to eq([argument])
+ ensure
+ remove_constants :User
+ end
+ end
+
+ describe "plus a word" do
+ let(:word) { "foobar" }
+ let(:scope_with_argument) { "#{scope}(#{argument})" }
+ let(:query) { "scope:#{scope_with_argument} #{word}" }
+
+ it "returns [scope], #arguments [argument] and #words [word]" do
+ begin
+ class User
+ def self.name_starts_with(_letter); end
+ end
+ search = Administrate::Search.new(resolver, query)
+ expect(search.words).to eq([word])
+ expect(search.scopes).to eq([scope])
+ expect(search.arguments).to eq([argument])
+ expect(search.scopes_with_arguments).to eq([scope_with_argument])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+ end
+
+ describe "the query contains a 'wildcarded' scope" do
+ let(:scope) { "name_starts_with" }
+ let(:argument) { "A" }
+ let(:query) { "#{scope}:#{argument}" }
+
+ it "returns the [scope] and #arguments the [argument] if configured" do
+ begin
+ class User
+ def self.name_starts_with(_letter); end
+ end
+ search = Administrate::Search.new(resolver, query)
+ expect(search.scopes).to eq([scope])
+ expect(search.arguments).to eq([argument])
+ ensure
+ remove_constants :User
+ end
+ end
+
+ describe "without the wildcard in the dashboard configuration" do
+ let(:resolver) do
+ double(resource_class: User,
+ dashboard_class: DashboardWithAnArrayOfScopes)
+ end
+
+ it "returns an empty array" do
+ begin
+ class User
+ def self.name_starts_with(_letter); end
+ end
+ search = Administrate::Search.new(resolver, query)
+ expect(search.scopes).to eq([])
+ expect(search.arguments).to eq([])
+ ensure
+ remove_constants :User
+ end
+ end
+ end
+ end
+ end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 582d843966..0acfc19b51 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,4 +1,5 @@
require "webmock/rspec"
+require "administrate/base_dashboard"
# http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
@@ -14,3 +15,38 @@
end
WebMock.disable_net_connect!(allow_localhost: true)
+
+class MockDashboard < Administrate::BaseDashboard
+ ATTRIBUTE_TYPES = {
+ name: Administrate::Field::String,
+ email: Administrate::Field::Email,
+ phone: Administrate::Field::Number,
+ }
+end
+
+class DashboardWithAnArrayOfScopes < Administrate::BaseDashboard
+ ATTRIBUTE_TYPES = {
+ name: Administrate::Field::String,
+ }
+
+ COLLECTION_SCOPES = [:active, :old, "with_argument(3)", "idle"]
+end
+
+class DashboardWithAHashOfScopes < Administrate::BaseDashboard
+ ATTRIBUTE_TYPES = {
+ name: Administrate::Field::String,
+ }
+
+ COLLECTION_SCOPES = {
+ status: [:active, :inactive, "idle", "with_argument:*"],
+ other: [:last_week, :old, "with_argument(3)",],
+ }
+end
+
+class DashboardWithScopesDisabled < Administrate::BaseDashboard
+ ATTRIBUTE_TYPES = {
+ name: Administrate::Field::String,
+ }
+
+ COLLECTION_SCOPES = []
+end