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 @@ +
+ <%= translated_scope scope_buttons_group, page.resource_name %>: +
+ <% page.scope_names(scope_buttons_group).each do |scope| %> + + <% end %> +
+
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