├── .rspec ├── app ├── assets │ ├── javascripts │ │ └── spree │ │ │ ├── backend │ │ │ └── spree_elasticsearch.js │ │ │ └── frontend │ │ │ └── spree_elasticsearch.js │ └── stylesheets │ │ └── spree │ │ ├── backend │ │ └── spree_elasticsearch.css │ │ └── frontend │ │ └── spree_elasticsearch.css ├── models │ └── spree │ │ ├── elasticsearch_settings.rb │ │ └── product_decorator.rb ├── views │ └── spree │ │ └── shared │ │ ├── _filter_properties.html.erb │ │ ├── _filters.html.erb │ │ └── _filter_price.html.erb └── helpers │ └── spree │ └── base_helper_decorator.rb ├── config ├── routes.rb └── locales │ └── en.yml ├── vendor └── assets │ ├── images │ └── sprite-skin-flat.png │ ├── stylesheets │ ├── ion.rangeSlider.skinFlat.css.erb │ └── ion.rangeSlider.css │ └── javascripts │ └── ion.rangeSlider.min.js ├── lib ├── spree_elasticsearch.rb ├── spree_elasticsearch │ ├── factories.rb │ └── engine.rb ├── spree │ └── search │ │ ├── elasticsearch │ │ └── facet.rb │ │ └── elasticsearch.rb ├── generators │ └── spree_elasticsearch │ │ └── install │ │ └── install_generator.rb └── tasks │ └── load_products.rake ├── .gitignore ├── bin └── rails ├── Gemfile ├── spec ├── product_helpers.rb ├── spec_helper.rb └── models │ └── products_spec.rb ├── Rakefile ├── spree_elasticsearch.gemspec ├── LICENSE └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color -------------------------------------------------------------------------------- /app/assets/javascripts/spree/backend/spree_elasticsearch.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spree/backend/spree_elasticsearch.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Spree::Core::Engine.routes.draw do 2 | # Add your extension routes here 3 | end 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/spree/frontend/spree_elasticsearch.js: -------------------------------------------------------------------------------- 1 | //= require spree/frontend 2 | //= require ion.rangeSlider.min -------------------------------------------------------------------------------- /vendor/assets/images/sprite-skin-flat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javereec/spree_elasticsearch/HEAD/vendor/assets/images/sprite-skin-flat.png -------------------------------------------------------------------------------- /lib/spree_elasticsearch.rb: -------------------------------------------------------------------------------- 1 | require 'spree_core' 2 | require 'elasticsearch/model' 3 | require 'elasticsearch/rails' 4 | require 'settingslogic' 5 | require 'virtus' 6 | require 'spree_elasticsearch/engine' 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | \#* 2 | *~ 3 | .#* 4 | .DS_Store 5 | .idea 6 | .project 7 | .sass-cache 8 | coverage 9 | Gemfile.lock 10 | tmp 11 | nbproject 12 | pkg 13 | *.swp 14 | spec/dummy 15 | config/elasticsearch.yml 16 | .ruby-version 17 | .ruby-gemset 18 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 2 | 3 | ENGINE_ROOT = File.expand_path('../..', __FILE__) 4 | ENGINE_PATH = File.expand_path('../../lib/spree_elasticsearch/engine', __FILE__) 5 | 6 | require 'rails/all' 7 | require 'rails/engine/commands' 8 | -------------------------------------------------------------------------------- /lib/spree_elasticsearch/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | # Define your Spree extensions Factories within this file to enable applications, and other extensions to use and override them. 3 | # 4 | # Example adding this to your spec_helper will load these Factories for use: 5 | # require 'spree_elasticsearch/factories' 6 | end 7 | -------------------------------------------------------------------------------- /lib/spree/search/elasticsearch/facet.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | module Search 3 | class Elasticsearch::Facet 4 | include ::Virtus.model 5 | 6 | attribute :name, String 7 | attribute :search_name, String # name for input in elasticsearch query 8 | attribute :type, String 9 | attribute :body, Hash 10 | end 11 | end 12 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'spree', github: 'spree/spree', branch: '3-0-stable' 4 | # Provides basic authentication functionality for testing parts of your engine 5 | gem 'spree_auth_devise', github: 'spree/spree_auth_devise', branch: '3-0-stable' 6 | gem 'spree_i18n', github: 'spree-contrib/spree_i18n', branch: '3-0-stable' 7 | 8 | gemspec 9 | -------------------------------------------------------------------------------- /spec/product_helpers.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | Product.class_eval do 3 | after_save :elasticsearch_index 4 | after_destroy :elasticsearch_delete 5 | 6 | private 7 | 8 | def elasticsearch_index 9 | self.__elasticsearch__.index_document 10 | end 11 | 12 | def elasticsearch_delete 13 | self.__elasticsearch__.delete_document 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rspec/core/rake_task' 5 | require 'spree/testing_support/extension_rake' 6 | 7 | RSpec::Core::RakeTask.new 8 | 9 | task :default => [:spec] 10 | 11 | desc 'Generates a dummy app for testing' 12 | task :test_app do 13 | ENV['LIB_NAME'] = 'spree_elasticsearch' 14 | Rake::Task['extension:test_app'].invoke 15 | end 16 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Sample localization file for English. Add more files in this directory for other locales. 2 | # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. 3 | 4 | en: 5 | spree: 6 | search_sorting: 7 | name_asc: Sort by name A - Z 8 | name_desc: Sort by name Z - A 9 | price_asc: Sort by price low - high 10 | price_desc: Sort by price high - low 11 | relevancy: Sort by relevancy 12 | -------------------------------------------------------------------------------- /lib/generators/spree_elasticsearch/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | module SpreeElasticsearch 2 | module Generators 3 | class InstallGenerator < Rails::Generators::Base 4 | source_root File.expand_path("../../templates", __FILE__) 5 | 6 | def add_javascripts 7 | append_file 'vendor/assets/javascripts/spree/frontend/all.js', "//= require spree/frontend/spree_elasticsearch\n" 8 | end 9 | 10 | def add_stylesheets 11 | inject_into_file 'vendor/assets/stylesheets/spree/frontend/all.css', " *= require spree/frontend/spree_elasticsearch\n", :before => /\*\//, :verbose => true 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/spree/elasticsearch_settings.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | class ElasticsearchSettings < Settingslogic 3 | def self.config_file 4 | path = "#{Rails.root}/config/elasticsearch.yml" 5 | return path if File.exists?(path) 6 | 7 | File.open(path, "w") do |file| 8 | file.puts(default_config) 9 | end 10 | path 11 | end 12 | 13 | def self.default_config 14 | <<-EOS 15 | defaults: &defaults 16 | hosts: ["127.0.0.1:9200"] 17 | bootstrap: true 18 | 19 | development: 20 | <<: *defaults 21 | index: development 22 | 23 | test: 24 | <<: *defaults 25 | index: test 26 | 27 | production: 28 | <<: *defaults 29 | index: production 30 | EOS 31 | end 32 | 33 | source config_file 34 | namespace Rails.env 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/spree_elasticsearch/engine.rb: -------------------------------------------------------------------------------- 1 | module SpreeElasticsearch 2 | class Engine < Rails::Engine 3 | require 'spree/core' 4 | isolate_namespace Spree 5 | engine_name 'spree_elasticsearch' 6 | 7 | config.autoload_paths += %W(#{config.root}/lib) 8 | 9 | # use rspec for tests 10 | config.generators do |g| 11 | g.test_framework :rspec 12 | end 13 | 14 | initializer "spree.assets.precompile", group: :all do |app| 15 | app.config.assets.precompile += %w[ 16 | sprite-skin-flat.png 17 | ] 18 | end 19 | 20 | def self.activate 21 | Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c| 22 | Rails.configuration.cache_classes ? require(c) : load(c) 23 | end 24 | end 25 | 26 | config.to_prepare &method(:activate).to_proc 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spree/frontend/spree_elasticsearch.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that includes stylesheets for spree_elasticsearch 3 | *= require normalize 4 | *= require skeleton 5 | *= require ion.rangeSlider 6 | *= require ion.rangeSlider.skinFlat 7 | */ 8 | 9 | .facet { 10 | margin: 0px 0px 12px 0px; 11 | padding: 0px 0px 6px 0px; 12 | } 13 | .facet h4 { 14 | font-weight: bold; 15 | font-size: 14px; 16 | text-transform: uppercase; 17 | background: #eee; 18 | margin: 0px; 19 | padding-left: 8px; 20 | } 21 | 22 | .facet-box { 23 | padding: 3px 6px 6px 6px; 24 | } 25 | 26 | .slider { 27 | padding: 0px 5px 0px 5px; 28 | } 29 | 30 | .slider-input { 31 | text-align: center; 32 | } 33 | 34 | .slider-input input[type="text"] { 35 | width: 75px; 36 | } 37 | 38 | .facet ul { 39 | list-style-type: none; 40 | padding: 0px; 41 | -webkit-margin-after: 0em; 42 | } -------------------------------------------------------------------------------- /app/views/spree/shared/_filter_properties.html.erb: -------------------------------------------------------------------------------- 1 | <% aggregations.each do |aggregation_name, aggregation| %> 2 |
3 |

<%= aggregation_name %>

4 |
5 | 17 |
18 |
19 | <% end %> 20 | -------------------------------------------------------------------------------- /app/views/spree/shared/_filters.html.erb: -------------------------------------------------------------------------------- 1 | <% unless @products.empty? %> 2 | <%= form_tag '', method: :get, id: 'sidebar_products_search' do %> 3 | <% params[:search] ||= {} %> 4 | <%= hidden_field_tag 'per_page', params[:per_page] %> 5 | <% aggregations = process_aggregations(@products.response.response['aggregations']) %> 6 | <%= select_tag 'sorting', options_for_select({ Spree.t('search_sorting.name_asc') => 'name_asc', Spree.t('search_sorting.name_desc') => 'name_desc', Spree.t('search_sorting.price_asc') => 'price_asc', Spree.t('search_sorting.price_desc') => 'price_desc', Spree.t('search_sorting.relevancy') => 'score' }, params[:sorting]), onchange: 'this.form.submit();' %> 7 | <%= render 'spree/shared/filter_price', aggregation: aggregations['price'] %> 8 | <%= render 'spree/shared/filter_properties', aggregations: aggregations.select { |key, aggregation| ((key != 'price') && (key != 'taxon_ids')) } || [] %> 9 | <%= submit_tag Spree.t(:search), name: nil %> 10 | <% end %> 11 | <% end %> 12 | -------------------------------------------------------------------------------- /lib/tasks/load_products.rake: -------------------------------------------------------------------------------- 1 | namespace :spree_elasticsearch do 2 | desc "Load all products into the index." 3 | task :load_products => :environment do 4 | unless Elasticsearch::Model.client.indices.exists index: Spree::ElasticsearchSettings.index 5 | Elasticsearch::Model.client.indices.create \ 6 | index: Spree::ElasticsearchSettings.index, 7 | body: { 8 | settings: { 9 | number_of_shards: 1, 10 | number_of_replicas: 0, 11 | analysis: { 12 | analyzer: { 13 | nGram_analyzer: { 14 | type: "custom", 15 | filter: ["lowercase", "asciifolding", "nGram_filter"], 16 | tokenizer: "whitespace" }, 17 | whitespace_analyzer: { 18 | type: "custom", 19 | filter: ["lowercase", "asciifolding"], 20 | tokenizer: "whitespace" }}, 21 | filter: { 22 | nGram_filter: { 23 | max_gram: "20", 24 | min_gram: "3", 25 | type: "nGram", 26 | token_chars: ["letter", "digit", "punctuation", "symbol"] }}}}, 27 | mappings: Spree::Product.mappings.to_hash } 28 | end 29 | Spree::Product.__elasticsearch__.import 30 | end 31 | end -------------------------------------------------------------------------------- /spree_elasticsearch.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | Gem::Specification.new do |s| 3 | s.platform = Gem::Platform::RUBY 4 | s.name = 'spree_elasticsearch' 5 | s.version = '3.0.0' 6 | s.summary = 'Add searching capabilities via Elasticsearch' 7 | s.description = s.summary 8 | s.required_ruby_version = '>= 1.9.3' 9 | 10 | s.author = 'Jan Vereecken' 11 | s.email = 'janvereecken@clubit.be' 12 | # s.homepage = 'http://www.spreecommerce.com' 13 | 14 | #s.files = `git ls-files`.split("\n") 15 | #s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.require_path = 'lib' 17 | s.requirements << 'none' 18 | 19 | s.add_dependency 'elasticsearch-model' 20 | s.add_dependency 'elasticsearch-rails' 21 | s.add_dependency 'settingslogic' 22 | s.add_dependency 'spree_core', '~> 3.0.0' 23 | s.add_dependency 'virtus' 24 | 25 | s.add_development_dependency 'capybara', '~> 2.1' 26 | s.add_development_dependency 'coffee-rails' 27 | s.add_development_dependency 'database_cleaner' 28 | s.add_development_dependency 'byebug' 29 | s.add_development_dependency 'factory_girl', '~> 4.2' 30 | s.add_development_dependency 'ffaker' 31 | s.add_development_dependency 'rspec-rails', '~> 2.13' 32 | s.add_development_dependency 'sass-rails' 33 | s.add_development_dependency 'selenium-webdriver' 34 | s.add_development_dependency 'simplecov' 35 | s.add_development_dependency 'sqlite3' 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 [name of plugin creator] 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name Spree nor the names of its contributors may be used to 13 | endorse or promote products derived from this software without specific 14 | prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /lib/spree/search/elasticsearch.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | module Search 3 | # The following search options are available. 4 | # * taxon 5 | # * keywords in name or description 6 | # * properties values 7 | class Elasticsearch < Spree::Core::Search::Base 8 | include ::Virtus.model 9 | 10 | attribute :query, String 11 | attribute :price_min, Float 12 | attribute :price_max, Float 13 | attribute :taxons, Array 14 | attribute :browse_mode, Boolean, default: true 15 | attribute :properties, Hash 16 | attribute :per_page, String 17 | attribute :page, String 18 | attribute :sorting, String 19 | 20 | def initialize(params) 21 | self.current_currency = Spree::Config[:currency] 22 | prepare(params) 23 | end 24 | 25 | def retrieve_products 26 | from = (@page - 1) * Spree::Config.products_per_page 27 | search_result = Spree::Product.__elasticsearch__.search( 28 | Spree::Product::ElasticsearchQuery.new( 29 | query: query, 30 | taxons: taxons, 31 | browse_mode: browse_mode, 32 | from: from, 33 | price_min: price_min, 34 | price_max: price_max, 35 | properties: properties, 36 | sorting: sorting 37 | ).to_hash 38 | ) 39 | search_result.limit(per_page).page(page).records 40 | end 41 | 42 | protected 43 | 44 | # converts params to instance variables 45 | def prepare(params) 46 | @query = params[:keywords] 47 | @sorting = params[:sorting] 48 | @taxons = params[:taxon] unless params[:taxon].nil? 49 | @browse_mode = params[:browse_mode] unless params[:browse_mode].nil? 50 | if params[:search] && params[:search][:price] 51 | # price 52 | @price_min = params[:search][:price][:min].to_f 53 | @price_max = params[:search][:price][:max].to_f 54 | # properties 55 | @properties = params[:search][:properties] 56 | end 57 | 58 | @per_page = (params[:per_page].to_i <= 0) ? Spree::Config[:products_per_page] : params[:per_page].to_i 59 | @page = (params[:page].to_i <= 0) ? 1 : params[:page].to_i 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/ion.rangeSlider.skinFlat.css.erb: -------------------------------------------------------------------------------- 1 | /* Ion.RangeSlider, Flat UI Skin 2 | // css version 1.8.5 3 | // by Denis Ineshin | ionden.com 4 | // ===================================================================================================================*/ 5 | 6 | /* ===================================================================================================================== 7 | // Skin details */ 8 | 9 | .irs-line-mid, 10 | .irs-line-left, 11 | .irs-line-right, 12 | .irs-diapason, 13 | .irs-slider { 14 | /*background: url("/assets/sprite-skin-flat.png") repeat-x;*/ 15 | background: url(<%= image_path("sprite-skin-flat.png") %>) repeat-x; 16 | } 17 | 18 | .irs { 19 | height: 40px; 20 | } 21 | .irs-with-grid { 22 | height: 60px; 23 | } 24 | .irs-line { 25 | height: 12px; top: 25px; 26 | } 27 | .irs-line-left { 28 | height: 12px; 29 | background-position: 0 -30px; 30 | } 31 | .irs-line-mid { 32 | height: 12px; 33 | background-position: 0 0; 34 | } 35 | .irs-line-right { 36 | height: 12px; 37 | background-position: 100% -30px; 38 | } 39 | 40 | .irs-diapason { 41 | height: 12px; top: 25px; 42 | background-position: 0 -60px; 43 | } 44 | 45 | .irs-slider { 46 | width: 16px; height: 18px; 47 | top: 22px; 48 | background-position: 0 -90px; 49 | } 50 | #irs-active-slider, .irs-slider:hover { 51 | background-position: 0 -120px; 52 | } 53 | 54 | .irs-min, .irs-max { 55 | color: #999; 56 | font-size: 10px; line-height: 1.333; 57 | text-shadow: none; 58 | top: 0; padding: 1px 3px; 59 | background: #e1e4e9; 60 | border-radius: 4px; 61 | } 62 | 63 | .irs-from, .irs-to, .irs-single { 64 | color: #fff; 65 | font-size: 10px; line-height: 1.333; 66 | text-shadow: none; 67 | padding: 1px 5px; 68 | background: #ed5565; 69 | border-radius: 4px; 70 | } 71 | .irs-from:after, .irs-to:after, .irs-single:after { 72 | position: absolute; display: block; content: ""; 73 | bottom: -6px; left: 50%; 74 | width: 0; height: 0; 75 | margin-left: -3px; 76 | overflow: hidden; 77 | border: 3px solid transparent; 78 | border-top-color: #ed5565; 79 | } 80 | 81 | 82 | .irs-grid-pol { 83 | background: #e1e4e9; 84 | } 85 | .irs-grid-text { 86 | color: #999; 87 | } 88 | 89 | .irs-disabled { 90 | } -------------------------------------------------------------------------------- /app/views/spree/shared/_filter_price.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

<%= aggregation.name %>

3 |
4 |
5 |
6 | 7 |
8 |
9 | 10 | to 11 | 12 |
13 |
14 |
15 | 16 | <% content_for :head do %> 17 | 65 | <% end %> 66 | -------------------------------------------------------------------------------- /app/helpers/spree/base_helper_decorator.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | BaseHelper.class_eval do 3 | # parses the properties facet result 4 | # input: Facet(name: "properties", type: "terms", body: {"terms" => [{"term" => "key1||value1", "count" => 1},{"term" => "key1||value2", "count" => 1}]}]) 5 | # output: Facet(name: key1, type: terms, body: {"terms" => [{"term" => "value1", "count" => 1},{"term" => "value2", "count" => 1}]}) 6 | def expand_properties_aggregation_to_aggregation_array(aggregation) 7 | # first step is to build a hash 8 | # {"property_name" => [{"term" => "value1", "count" => 1},{"term" => "value2", "count" => 1}]}} 9 | property_names = {} 10 | aggregation[:buckets].each do |term| 11 | t = term[:key].split('||') 12 | property_name = t[0] 13 | property_value = t[1] 14 | # add a search_term to each term hash to allow searching on the element later on 15 | property = { term: property_value, count: term[:doc_count], search_term: term[:key] } 16 | if property_names.has_key?(property_name) 17 | property_names[property_name] << property 18 | else 19 | property_names[property_name] = [property] 20 | end 21 | end 22 | # next step is to transform the hash to facet objects 23 | # this allows us to handle it in a uniform way 24 | # format: Facet(name: "property_name", type: type, body: {"terms" => [{"term" => "value1", "count" => 1},{"term" => "value2", "count" => 1}]}]) 25 | result = {} 26 | property_names.each do |key,value| 27 | value.sort_by!{ |value| [-value[:count], value[:term].downcase] } # first sort on desc, then on term asc 28 | # result << Spree::Search::Elasticsearch::Facet.new(name: key, search_name: facet.name, type: facet.type, body: {"terms" => value}) 29 | result[key] = { 30 | 'terms' => value 31 | } 32 | end 33 | result 34 | end 35 | 36 | # Helper method for interpreting facets from Elasticsearch. Something like a before filter. 37 | # Sorting, changings things, the world is your oyster 38 | # Input is a hash 39 | def process_aggregations(aggregations) 40 | new_aggregations = {} 41 | delete_keys = [] 42 | aggregations.map do |key, aggregation| 43 | if key == 'properties' 44 | new_aggregations.merge! expand_properties_aggregation_to_aggregation_array(aggregation) 45 | delete_keys << :properties 46 | else 47 | aggregation 48 | end 49 | end 50 | delete_keys.each { |key| aggregations.delete(key) } 51 | aggregations.merge! new_aggregations 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Run Coverage report 2 | require 'simplecov' 3 | SimpleCov.start do 4 | add_filter 'spec/dummy' 5 | add_group 'Controllers', 'app/controllers' 6 | add_group 'Helpers', 'app/helpers' 7 | add_group 'Mailers', 'app/mailers' 8 | add_group 'Models', 'app/models' 9 | add_group 'Views', 'app/views' 10 | add_group 'Libraries', 'lib' 11 | end 12 | 13 | # Configure Rails Environment 14 | ENV['RAILS_ENV'] = 'test' 15 | 16 | require File.expand_path('../dummy/config/environment.rb', __FILE__) 17 | 18 | require 'rspec/rails' 19 | require 'database_cleaner' 20 | require 'ffaker' 21 | 22 | # Requires supporting ruby files with custom matchers and macros, etc, 23 | # in spec/support/ and its subdirectories. 24 | Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |f| require f } 25 | 26 | # Requires factories defined in spree_core 27 | require 'spree/testing_support/factories' 28 | require 'spree/testing_support/controller_requests' 29 | require 'spree/testing_support/authorization_helpers' 30 | require 'spree/testing_support/url_helpers' 31 | 32 | # Requires factories defined in lib/spree_elasticsearch/factories.rb 33 | require 'spree_elasticsearch/factories' 34 | 35 | # Allow for indexing 36 | require 'product_helpers' 37 | 38 | RSpec.configure do |config| 39 | config.include FactoryGirl::Syntax::Methods 40 | 41 | # == URL Helpers 42 | # 43 | # Allows access to Spree's routes in specs: 44 | # 45 | # visit spree.admin_path 46 | # current_path.should eql(spree.products_path) 47 | config.include Spree::TestingSupport::UrlHelpers 48 | 49 | # == Mock Framework 50 | # 51 | # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: 52 | # 53 | # config.mock_with :mocha 54 | # config.mock_with :flexmock 55 | # config.mock_with :rr 56 | config.mock_with :rspec 57 | config.color = true 58 | 59 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 60 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 61 | 62 | # Capybara javascript drivers require transactional fixtures set to false, and we use DatabaseCleaner 63 | # to cleanup after each test instead. Without transactional fixtures set to false the records created 64 | # to setup a test will be unavailable to the browser, which runs under a seperate server instance. 65 | config.use_transactional_fixtures = false 66 | 67 | # Ensure Suite is set to use transactions for speed. 68 | config.before :suite do 69 | DatabaseCleaner.strategy = :transaction 70 | DatabaseCleaner.clean_with :truncation 71 | end 72 | 73 | # Before each spec check if it is a Javascript test and switch between using database transactions or not where necessary. 74 | config.before :each do 75 | DatabaseCleaner.strategy = example.metadata[:js] ? :truncation : :transaction 76 | DatabaseCleaner.start 77 | end 78 | 79 | # After each spec clean the database. 80 | config.after :each do 81 | DatabaseCleaner.clean 82 | end 83 | 84 | config.fail_fast = ENV['FAIL_FAST'] || false 85 | end 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spree Elasticsearch 2 | 3 | This extension uses elasticsearch-ruby for integration of Elasticsearch with Spree. This is preconfigured for a certain use case, but by all means override where necessary. 4 | 5 | To understand what is going on, you should first learn about Elasticsearch. Some great resources: 6 | 7 | * http://exploringelasticsearch.com is an excellent introduction to Elasticsearch 8 | * http://elastichammer.exploringelasticsearch.com/ is a tool to test queries against your own Elasticsearch cluster 9 | * https://www.found.no/play/ is an another online tool that can be used to play with Elasticsearch. The online version communicates with an online cluster run by Found. 10 | 11 | ## Installation 12 | 13 | Add spree_elasticsearch to your Gemfile: 14 | 15 | ```ruby 16 | gem 'spree_elasticsearch', github: 'javereec/spree_elasticsearch', branch: '3-0-stable' 17 | ``` 18 | 19 | Bundle your dependencies and run the installation generator: 20 | 21 | ```shell 22 | bundle 23 | touch config/elasticsearch.yml # temporary install workaround 24 | bundle exec rails g spree_elasticsearch:install 25 | ``` 26 | 27 | Edit the file in `config/elasticsearch.yml` to match your configuration. 28 | 29 | Edit the spree initializer in `config/initializers/spree.rb` and use the elasticsearch searcher. 30 | 31 | ```ruby 32 | Spree.config do |config| 33 | config.searcher_class = Spree::Search::Elasticsearch 34 | end 35 | ``` 36 | 37 | Create a decorator for Product model to implement callbacks and update the index. This gem doesn't implement a default method as there are several options. For sites with lots of products and changes to these products, the recommended way would be to use a sidekiq task. Check the [elasticsearch-rails](https://github.com/elasticsearch/elasticsearch-rails/tree/master/elasticsearch-model#updating-the-documents-in-the-index) documentation for different options. 38 | 39 | For example using the model callbacks 40 | 41 | ```ruby 42 | module Spree 43 | Product.class_eval do 44 | include Elasticsearch::Model::Callbacks 45 | end 46 | end 47 | ``` 48 | 49 | ### Elasticsearch 50 | 51 | Elasticsearch is very easy to install. Get and unzip elasticsearch 1.x.x: http://www.elasticsearch.org/download 52 | 53 | Start: 54 | 55 | ```shell 56 | bin/elasticsearch 57 | ``` 58 | 59 | Execute following to drop index (all) and have a fresh start: 60 | 61 | ```shell 62 | curl -XDELETE 'http://localhost:9200' 63 | ``` 64 | 65 | Elasticsearch has a nifty plugin, called Marvel, you can install to view the status of the cluster, but which can also serve as a tool to debug the commands you're running against the cluser. This tool is free for development purposes, but requires a license for production environments. You can install it by executing the following. 66 | 67 | ```shell 68 | bin/plugin -i elasticsearch/marvel/latest 69 | ``` 70 | 71 | ## Testing 72 | 73 | Be sure to bundle your dependencies and then create a dummy test app for the specs to run against. 74 | 75 | ```shell 76 | bundle 77 | bundle exec rake test_app 78 | bundle exec rspec spec 79 | ``` 80 | 81 | When testing your applications integration with this extension you may use it's factories. 82 | Simply add this require statement to your spec_helper: 83 | 84 | ```ruby 85 | require 'spree_elasticsearch/factories' 86 | ``` 87 | 88 | Please note that these test require an actual instance of Elasticsearch. ] 89 | A helper product decorator class is includes to update the index when saving a product. 90 | 91 | Copyright (c) 2014-2015 Jan Vereecken, released under the New BSD License 92 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/ion.rangeSlider.css: -------------------------------------------------------------------------------- 1 | /* Ion.RangeSlider 2 | // css version 1.8.5 3 | // by Denis Ineshin | ionden.com 4 | // ===================================================================================================================*/ 5 | 6 | /* ===================================================================================================================== 7 | // RangeSlider */ 8 | 9 | .irs { 10 | position: relative; display: block; 11 | } 12 | .irs-line { 13 | position: relative; display: block; 14 | overflow: hidden; 15 | } 16 | .irs-line-left, .irs-line-mid, .irs-line-right { 17 | position: absolute; display: block; 18 | top: 0; 19 | } 20 | .irs-line-left { 21 | left: 0; width: 10%; 22 | } 23 | .irs-line-mid { 24 | left: 10%; width: 80%; 25 | } 26 | .irs-line-right { 27 | right: 0; width: 10%; 28 | } 29 | 30 | .irs-diapason { 31 | position: absolute; display: block; 32 | left: 0; width: 100%; 33 | } 34 | .irs-slider { 35 | position: absolute; display: block; 36 | cursor: default; 37 | z-index: 1; 38 | } 39 | .irs-slider.single { 40 | left: 10px; 41 | } 42 | .irs-slider.single:before { 43 | position: absolute; display: block; content: ""; 44 | top: -30%; left: -30%; 45 | width: 160%; height: 160%; 46 | background: rgba(0,0,0,0.0); 47 | } 48 | .irs-slider.from { 49 | left: 100px; 50 | } 51 | .irs-slider.from:before { 52 | position: absolute; display: block; content: ""; 53 | top: -30%; left: -30%; 54 | width: 130%; height: 160%; 55 | background: rgba(0,0,0,0.0); 56 | } 57 | .irs-slider.to { 58 | left: 300px; 59 | } 60 | .irs-slider.to:before { 61 | position: absolute; display: block; content: ""; 62 | top: -30%; left: 0; 63 | width: 130%; height: 160%; 64 | background: rgba(0,0,0,0.0); 65 | } 66 | .irs-slider.last { 67 | z-index: 2; 68 | } 69 | 70 | .irs-min { 71 | position: absolute; display: block; 72 | left: 0; 73 | cursor: default; 74 | } 75 | .irs-max { 76 | position: absolute; display: block; 77 | right: 0; 78 | cursor: default; 79 | } 80 | 81 | .irs-from, .irs-to, .irs-single { 82 | position: absolute; display: block; 83 | top: 0; left: 0; 84 | cursor: default; 85 | white-space: nowrap; 86 | } 87 | 88 | 89 | .irs-grid { 90 | position: absolute; display: none; 91 | bottom: 0; left: 0; 92 | width: 100%; height: 20px; 93 | } 94 | .irs-with-grid .irs-grid { 95 | display: block; 96 | } 97 | .irs-grid-pol { 98 | position: absolute; 99 | top: 0; left: 0; 100 | width: 1px; height: 8px; 101 | background: #000; 102 | } 103 | .irs-grid-pol.small { 104 | height: 4px; 105 | } 106 | .irs-grid-text { 107 | position: absolute; 108 | bottom: 0; left: 0; 109 | width: 100px; 110 | white-space: nowrap; 111 | text-align: center; 112 | font-size: 9px; line-height: 9px; 113 | color: #000; 114 | } 115 | 116 | .irs-disable-mask { 117 | position: absolute; display: block; 118 | top: 0; left: 0; 119 | width: 100%; height: 100%; 120 | cursor: default; 121 | background: rgba(0,0,0,0.0); 122 | z-index: 2; 123 | } 124 | .irs-disabled { 125 | opacity: 0.4; 126 | } -------------------------------------------------------------------------------- /app/models/spree/product_decorator.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | Product.class_eval do 3 | include Elasticsearch::Model 4 | 5 | index_name Spree::ElasticsearchSettings.index 6 | document_type 'spree_product' 7 | 8 | mapping _all: { analyzer: 'nGram_analyzer', search_analyzer: 'whitespace_analyzer' } do 9 | indexes :name, type: 'multi_field' do 10 | indexes :name, type: 'string', analyzer: 'nGram_analyzer', boost: 100 11 | indexes :untouched, type: 'string', include_in_all: false, index: 'not_analyzed' 12 | end 13 | 14 | indexes :description, analyzer: 'snowball' 15 | indexes :available_on, type: 'date', format: 'dateOptionalTime', include_in_all: false 16 | indexes :price, type: 'double' 17 | indexes :sku, type: 'string', index: 'not_analyzed' 18 | indexes :taxon_ids, type: 'string', index: 'not_analyzed' 19 | indexes :properties, type: 'string', index: 'not_analyzed' 20 | end 21 | 22 | def as_indexed_json(options={}) 23 | result = as_json({ 24 | methods: [:price, :sku], 25 | only: [:available_on, :description, :name], 26 | include: { 27 | variants: { 28 | only: [:sku], 29 | include: { 30 | option_values: { 31 | only: [:name, :presentation] 32 | } 33 | } 34 | } 35 | } 36 | }) 37 | result[:properties] = property_list unless property_list.empty? 38 | result[:taxon_ids] = taxons.map(&:self_and_ancestors).flatten.uniq.map(&:id) unless taxons.empty? 39 | result 40 | end 41 | 42 | def self.get(product_id) 43 | Elasticsearch::Model::Response::Result.new(__elasticsearch__.client.get index: index_name, type: document_type, id: product_id) 44 | end 45 | 46 | # Inner class used to query elasticsearch. The idea is that the query is dynamically build based on the parameters. 47 | class Product::ElasticsearchQuery 48 | include ::Virtus.model 49 | 50 | attribute :from, Integer, default: 0 51 | attribute :price_min, Float 52 | attribute :price_max, Float 53 | attribute :properties, Hash 54 | attribute :query, String 55 | attribute :taxons, Array 56 | attribute :browse_mode, Boolean 57 | attribute :sorting, String 58 | 59 | # When browse_mode is enabled, the taxon filter is placed at top level. This causes the results to be limited, but facetting is done on the complete dataset. 60 | # When browse_mode is disabled, the taxon filter is placed inside the filtered query. This causes the facets to be limited to the resulting set. 61 | 62 | # Method that creates the actual query based on the current attributes. 63 | # The idea is to always to use the following schema and fill in the blanks. 64 | # { 65 | # query: { 66 | # filtered: { 67 | # query: { 68 | # query_string: { query: , fields: [] } 69 | # } 70 | # filter: { 71 | # and: [ 72 | # { terms: { taxons: [] } }, 73 | # { terms: { properties: [] } } 74 | # ] 75 | # } 76 | # } 77 | # } 78 | # filter: { range: { price: { lte: , gte: } } }, 79 | # sort: [], 80 | # from: , 81 | # aggregations: 82 | # } 83 | def to_hash 84 | q = { match_all: {} } 85 | unless query.blank? # nil or empty 86 | q = { query_string: { query: query, fields: ['name^5','description','sku'], default_operator: 'AND', use_dis_max: true } } 87 | end 88 | query = q 89 | 90 | and_filter = [] 91 | unless @properties.nil? || @properties.empty? 92 | # transform properties from [{"key1" => ["value_a","value_b"]},{"key2" => ["value_a"]} 93 | # to { terms: { properties: ["key1||value_a","key1||value_b"] } 94 | # { terms: { properties: ["key2||value_a"] } 95 | # This enforces "and" relation between different property values and "or" relation between same property values 96 | properties = @properties.map{ |key, value| [key].product(value) }.map do |pair| 97 | and_filter << { terms: { properties: pair.map { |property| property.join('||') } } } 98 | end 99 | end 100 | 101 | sorting = case @sorting 102 | when 'name_asc' 103 | [ { 'name.untouched' => { order: 'asc' } }, { price: { order: 'asc' } }, '_score' ] 104 | when 'name_desc' 105 | [ { 'name.untouched' => { order: 'desc' } }, { price: { order: 'asc' } }, '_score' ] 106 | when 'price_asc' 107 | [ { 'price' => { order: 'asc' } }, { 'name.untouched' => { order: 'asc' } }, '_score' ] 108 | when 'price_desc' 109 | [ { 'price' => { order: 'desc' } }, { 'name.untouched' => { order: 'asc' } }, '_score' ] 110 | when 'score' 111 | [ '_score', { 'name.untouched' => { order: 'asc' } }, { price: { order: 'asc' } } ] 112 | else 113 | [ { 'name.untouched' => { order: 'asc' } }, { price: { order: 'asc' } }, '_score' ] 114 | end 115 | 116 | # aggregations 117 | aggregations = { 118 | price: { stats: { field: 'price' } }, 119 | properties: { terms: { field: 'properties', order: { _count: 'asc' }, size: 1000000 } }, 120 | taxon_ids: { terms: { field: 'taxon_ids', size: 1000000 } } 121 | } 122 | 123 | # basic skeleton 124 | result = { 125 | min_score: 0.1, 126 | query: { filtered: {} }, 127 | sort: sorting, 128 | from: from, 129 | aggregations: aggregations 130 | } 131 | 132 | # add query and filters to filtered 133 | result[:query][:filtered][:query] = query 134 | # taxon and property filters have an effect on the facets 135 | and_filter << { terms: { taxon_ids: taxons } } unless taxons.empty? 136 | # only return products that are available 137 | and_filter << { range: { available_on: { lte: 'now' } } } 138 | result[:query][:filtered][:filter] = { and: and_filter } unless and_filter.empty? 139 | 140 | # add price filter outside the query because it should have no effect on facets 141 | if price_min && price_max && (price_min < price_max) 142 | result[:filter] = { range: { price: { gte: price_min, lte: price_max } } } 143 | end 144 | 145 | result 146 | end 147 | end 148 | 149 | private 150 | 151 | def property_list 152 | product_properties.map{|pp| "#{pp.property.name}||#{pp.value}"} 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /spec/models/products_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'byebug' 3 | 4 | module Spree 5 | describe Product do 6 | let(:a_product) { create(:product) } 7 | let(:another_product) { create(:product) } 8 | 9 | before do 10 | # for clean testing, delete index, create new one and create/update mapping 11 | Product.delete_all 12 | client = Elasticsearch::Client.new log: true, hosts: ElasticsearchSettings.hosts 13 | 14 | if Elasticsearch::Model.client.indices.exists index: Spree::ElasticsearchSettings.index 15 | client.indices.delete index: ElasticsearchSettings.index 16 | end 17 | 18 | client.indices.create \ 19 | index: ElasticsearchSettings.index, 20 | body: { 21 | settings: { 22 | number_of_shards: 1, 23 | number_of_replicas: 0, 24 | analysis: { 25 | analyzer: { 26 | nGram_analyzer: { 27 | type: 'custom', 28 | filter: %w(lowercase asciifolding nGram_filter), 29 | tokenizer: 'whitespace' }, 30 | whitespace_analyzer: { 31 | type: 'custom', 32 | filter: %w(lowercase asciifolding), 33 | tokenizer: 'whitespace' }}, 34 | filter: { 35 | nGram_filter: { 36 | max_gram: '20', 37 | min_gram: '3', 38 | type: 'nGram', 39 | token_chars: %w(letter digit punctuation symbol) 40 | } 41 | } 42 | } 43 | }, 44 | mappings: Spree::Product.mappings.to_hash 45 | } 46 | end 47 | 48 | context '#index' do 49 | before { a_product.name = 'updated name' } 50 | 51 | it 'updates an existing product in the index' do 52 | expect(a_product.__elasticsearch__.index_document['_version']).to eq 2 53 | expect(Product.get(a_product.id).name).to eq 'updated name' 54 | end 55 | end 56 | 57 | context 'get' do 58 | subject { Product.get(a_product.id).name } 59 | 60 | it { is_expected.to eq a_product.name } 61 | end 62 | 63 | describe 'search' do 64 | context 'retrieves a product based on name' do 65 | before do 66 | another_product.name = 'Foobar' 67 | another_product.__elasticsearch__.index_document 68 | Product.__elasticsearch__.refresh_index! 69 | end 70 | 71 | let(:products) do 72 | Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new(query: another_product.name)) 73 | end 74 | 75 | it { expect(products.results.total).to eq 1 } 76 | it { expect(products.results.any?{ |product| product.name == another_product.name }).to be_truthy } 77 | end 78 | 79 | context 'retrieves products based on part of the name' do 80 | before do 81 | a_product.name = 'Product 1' 82 | another_product.name = 'Product 2' 83 | a_product.__elasticsearch__.index_document 84 | another_product.__elasticsearch__.index_document 85 | Product.__elasticsearch__.refresh_index! 86 | end 87 | 88 | let(:products) { Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new(query: 'Product')) } 89 | 90 | it { expect(products.results.total).to eq 2 } 91 | it { expect(products.results.any?{ |product| product.name == a_product.name }).to be_truthy } 92 | it { expect(products.results.any?{ |product| product.name == another_product.name }).to be_truthy } 93 | end 94 | 95 | context 'retrieves products default sorted on name' do 96 | before do 97 | a_product.name = 'Product 1' 98 | a_product.__elasticsearch__.index_document 99 | another_product.name = 'Product 2' 100 | another_product.__elasticsearch__.index_document 101 | Product.__elasticsearch__.refresh_index! 102 | end 103 | 104 | let(:products) { Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new) } 105 | 106 | it { expect(products.results.total).to eq 2 } 107 | it { expect(products.results.to_a[0].name).to eq a_product.name } 108 | it { expect(products.results.to_a[1].name).to eq another_product.name } 109 | end 110 | 111 | context 'filters products based on price' do 112 | before do 113 | a_product.price = 1 114 | a_product.__elasticsearch__.index_document 115 | another_product.price = 3 116 | another_product.__elasticsearch__.index_document 117 | Product.__elasticsearch__.refresh_index! 118 | end 119 | 120 | let(:products) do 121 | Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new(price_min: 2, price_max: 4)) 122 | end 123 | 124 | it { expect(products.results.total).to eq 1 } 125 | it { expect(products.results.to_a[0].name).to eq another_product.name } 126 | end 127 | 128 | context 'ignores price filter when price_min is greater than price_max' do 129 | before do 130 | a_product.price = 1 131 | a_product.__elasticsearch__.index_document 132 | another_product.price = 3 133 | another_product.__elasticsearch__.index_document 134 | Product.__elasticsearch__.refresh_index! 135 | end 136 | 137 | let(:products) do 138 | Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new(price_min: 4, price_max: 2)) 139 | end 140 | 141 | it { expect(products.results.total).to eq 2 } 142 | end 143 | 144 | context 'ignores price filter when price_min and/or price_max is nil' do 145 | before do 146 | a_product.price = 1 147 | a_product.__elasticsearch__.index_document 148 | another_product.price = 3 149 | another_product.__elasticsearch__.index_document 150 | Product.__elasticsearch__.refresh_index! 151 | end 152 | 153 | let(:products_min_price) do 154 | Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new(price_min: 2)) 155 | end 156 | let(:products_max_price) do 157 | Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new(price_max: 2)) 158 | end 159 | let(:products) do 160 | Product.__elasticsearch__.search(Spree::Product::ElasticsearchQuery.new(price_min: nil, price_max: nil)) 161 | end 162 | 163 | it { expect(products_min_price.results.total).to eq 2 } 164 | it { expect(products_max_price.results.total).to eq 2 } 165 | it { expect(products.results.total).to eq 2 } 166 | end 167 | 168 | describe 'properties' do 169 | let(:product) { Product.find(a_product.id) } 170 | 171 | subject { products.results } 172 | 173 | context 'allows searching on property' do 174 | before do 175 | a_product.set_property('the_prop', 'a_value') 176 | product.save 177 | Product.__elasticsearch__.refresh_index! 178 | end 179 | 180 | 181 | let(:products) do 182 | Product.__elasticsearch__.search( 183 | Spree::Product::ElasticsearchQuery.new(properties: { the_prop: %w(a_value b_value) }) 184 | ) 185 | end 186 | 187 | it { expect(subject.count).to eq 1 } 188 | it { expect(subject.to_a[0].name).to eq product.name } 189 | end 190 | 191 | context 'allows searching on different property values (OR relation)' do 192 | before do 193 | a_product.set_property('the_prop', 'a_value') 194 | another_product.set_property('the_prop', 'b_value') 195 | product_one.save 196 | product_two.save 197 | Product.__elasticsearch__.refresh_index! 198 | end 199 | 200 | let(:product_one) { Product.find(a_product.id) } 201 | let(:product_two) { Product.find(another_product.id) } 202 | 203 | let(:products) do 204 | Product.__elasticsearch__.search( 205 | Spree::Product::ElasticsearchQuery.new(properties: { the_prop: %w(a_value b_value) }) 206 | ) 207 | end 208 | 209 | it { expect(subject.count).to eq 2 } 210 | it { expect(subject.to_a.find { |product| product.name == product_one.name }).to_not be_nil } 211 | it { expect(subject.to_a.find { |product| product.name == product_two.name }).to_not be_nil } 212 | end 213 | 214 | context 'allows searching on different properties (AND relation)' do 215 | before do 216 | a_product.set_property('the_prop', 'a_value') 217 | a_product.set_property('another_prop', 'a_value') 218 | product.save 219 | another_product.set_property('the_prop', 'a_value') 220 | another_product.set_property('another_prop', 'b_value') 221 | Product.find(another_product.id).save 222 | Product.__elasticsearch__.refresh_index! 223 | end 224 | 225 | let(:products) do 226 | Product.__elasticsearch__.search( 227 | Spree::Product::ElasticsearchQuery.new(properties: { the_prop: ['a_value'], another_prop: ['a_value'] }) 228 | ) 229 | end 230 | 231 | it { expect(subject.count).to eq 1 } 232 | it { expect(subject.to_a[0].name).to eq product.name } 233 | end 234 | end 235 | end 236 | 237 | context 'document_type returns the name of the class' do 238 | subject { Product.document_type } 239 | 240 | it { is_expected.to eq 'spree_product' } 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/ion.rangeSlider.min.js: -------------------------------------------------------------------------------- 1 | // Ion.RangeSlider 2 | // version 1.9.0 3 | // https://github.com/IonDen/ion.rangeSlider 4 | (function(c,ea,$,L){var aa=0,s,T=function(){var c=L.userAgent,a=/msie\s\d+/i;return 0c)?!0:!1}(),X="ontouchstart"in $||0a.max&&(a.from=a.min);a.toa.max&&(a.to=a.max);"double"===a.type&&(a.from>a.to&&(a.from=a.to),a.to';e[0].style.display="none";e.before(g);var p=e.prev(),N=c(ea.body),O=c($),q,C,D,A,B,x,w,m,t,r,G,L,v=!1,y=!1,I=!0,f={},U=0,P=0,Q=0,l=0,E=0,F=0,V=0,R=0,S=0,Y=0,u=0;parseInt(a.step, 10 | 10)!==parseFloat(a.step)&&(u=a.step.toString().split(".")[1],u=Math.pow(10,u.length));this.updateData=function(b){I=!0;a=c.extend(a,b);p.find("*").off();O.off("mouseup.irs"+n.pluginCount);N.off("mouseup.irs"+n.pluginCount);N.off("mousemove.irs"+n.pluginCount);p.html("");ba()};this.removeSlider=function(){p.find("*").off();O.off("mouseup.irs"+n.pluginCount);N.off("mouseup.irs"+n.pluginCount);N.off("mousemove.irs"+n.pluginCount);p.html("").remove();e.data("isActive",!1);e.show()};var ba=function(){p.html('01000'); 11 | q=p.find(".irs");C=q.find(".irs-min");D=q.find(".irs-max");A=q.find(".irs-from");B=q.find(".irs-to");x=q.find(".irs-single");L=p.find(".irs-grid");a.hideMinMax&&(C[0].style.display="none",D[0].style.display="none",Q=P=0);a.hideFromTo&&(A[0].style.display="none",B[0].style.display="none",x[0].style.display="none");a.hideMinMax||(a.values?(C.html(a.prefix+a.values[0]+a.postfix),D.html(a.prefix+a.values[a.values.length-1]+a.maxPostfix+a.postfix)):(C.html(a.prefix+z(a.min)+a.postfix),D.html(a.prefix+ 12 | z(a.max)+a.maxPostfix+a.postfix)),P=C.outerWidth(),Q=D.outerWidth());if("single"===a.type){if(q.append(''),w=q.find(".single"),w.on("mousedown",function(a){a.preventDefault();a.stopPropagation();J(a,c(this),null);y=v=!0;s=n.pluginCount;T&&c("*").prop("unselectable",!0)}),X)w.on("touchstart",function(a){a.preventDefault();a.stopPropagation();J(a.originalEvent.touches[0],c(this),null);y=v=!0;s=n.pluginCount})}else"double"===a.type&&(q.append(''), 13 | m=q.find(".from"),t=q.find(".to"),G=q.find(".irs-diapason"),K(),m.on("mousedown",function(a){a.preventDefault();a.stopPropagation();c(this).addClass("last");t.removeClass("last");J(a,c(this),"from");y=v=!0;s=n.pluginCount;T&&c("*").prop("unselectable",!0)}),t.on("mousedown",function(a){a.preventDefault();a.stopPropagation();c(this).addClass("last");m.removeClass("last");J(a,c(this),"to");y=v=!0;s=n.pluginCount;T&&c("*").prop("unselectable",!0)}),X&&(m.on("touchstart",function(a){a.preventDefault(); 14 | a.stopPropagation();c(this).addClass("last");t.removeClass("last");J(a.originalEvent.touches[0],c(this),"from");y=v=!0;s=n.pluginCount}),t.on("touchstart",function(a){a.preventDefault();a.stopPropagation();c(this).addClass("last");m.removeClass("last");J(a.originalEvent.touches[0],c(this),"to");y=v=!0;s=n.pluginCount})),a.to===a.max&&m.addClass("last"));O.on("mouseup.irs"+n.pluginCount,function(){s===n.pluginCount&&v&&(v=y=!1,r.removeAttr("id"),r=null,"double"===a.type&&K(),Z(),T&&c("*").prop("unselectable", 15 | !1))});N.on("mousemove.irs"+n.pluginCount,function(a){v&&(U=a.pageX,W())});p.on("mousedown",function(){s=n.pluginCount});p.on("mouseup",function(b){if(s===n.pluginCount&&!v&&!a.disable){b=b.pageX-p.offset().left;var d=f.fromX+(f.toX-f.fromX)/2;R=0;V=q.width()-F;S=q.width()-F;"single"===a.type?(r=w,r.attr("id","irs-active-slider"),W(b)):"double"===a.type&&(r=b<=d?m:t,r.attr("id","irs-active-slider"),W(b),K());r.removeAttr("id");r=null}});X&&(O.on("touchend",function(){v&&(v=y=!1,r.removeAttr("id"), 16 | r=null,"double"===a.type&&K(),Z())}),O.on("touchmove",function(a){v&&(U=a.originalEvent.touches[0].pageX,W())}));ca();ga();a.hasGrid&&ha();a.disable?(p.addClass("irs-disabled"),p.append('')):(p.removeClass("irs-disabled"),p.find(".irs-disable-mask").remove())},ca=function(){l=q.width();F=w?w.width():m.width();E=l-F},J=function(b,d,h){ca();I=!1;r=d;r.attr("id","irs-active-slider");d=r.offset().left;Y=d+(b.pageX-d)-r.position().left;"single"===a.type?V=q.width()- 17 | F:"double"===a.type&&("from"===h?(R=0,S=parseInt(t.css("left"),10)):(R=parseInt(m.css("left"),10),S=q.width()-F))},K=function(){var a=m.width(),d=c.data(m[0],"x")||parseInt(m[0].style.left,10)||m.position().left,h=(c.data(t[0],"x")||parseInt(t[0].style.left,10)||t.position().left)-d;G[0].style.left=d+a/2+"px";G[0].style.width=h+"px"},W=function(b){var d=U-Y,d=b?b:U-Y;"single"===a.type?(0>d&&(d=0),d>V&&(d=V)):"double"===a.type&&(dS&&(d=S),K());c.data(r[0],"x",d);Z();b=Math.round(d);r[0].style.left= 18 | b+"px"},Z=function(){var b={input:e,slider:p,min:a.min,max:a.max,fromNumber:0,toNumber:0,fromPers:0,toPers:0,fromX:0,fromX_pure:0,toX:0,toX_pure:0},d=a.max-a.min,h;"single"===a.type?(b.fromX=c.data(w[0],"x")||parseInt(w[0].style.left,10)||w.position().left,b.fromPers=b.fromX/E*100,h=d/100*b.fromPers+a.min,b.fromNumber=Math.round(h/a.step)*a.step,b.fromNumbera.max&&(b.fromNumber=a.max),u&&(b.fromNumber=parseInt(b.fromNumber*u,10)/u),H&&(b.fromValue=a.values[b.fromNumber])): 19 | "double"===a.type&&(b.fromX=c.data(m[0],"x")||parseInt(m[0].style.left,10)||m.position().left,b.fromPers=b.fromX/E*100,h=d/100*b.fromPers+a.min,b.fromNumber=Math.round(h/a.step)*a.step,b.fromNumbera.max&&(b.toNumber=a.max),u&&(b.fromNumber=parseInt(b.fromNumber*u,10)/u,b.toNumber=parseInt(b.toNumber*u,10)/ 20 | u),H&&(b.fromValue=a.values[b.fromNumber],b.toValue=a.values[b.toNumber]));f=b;da()},ga=function(){var b={input:e,slider:p,min:a.min,max:a.max,fromNumber:a.from,toNumber:a.to,fromPers:0,toPers:0,fromX:0,fromX_pure:0,toX:0,toX_pure:0},d=a.max-a.min;"single"===a.type?(b.fromPers=0!==d?(b.fromNumber-a.min)/d*100:0,b.fromX_pure=E/100*b.fromPers,b.fromX=Math.round(b.fromX_pure),w[0].style.left=b.fromX+"px",c.data(w[0],"x",b.fromX_pure)):"double"===a.type&&(b.fromPers=0!==d?(b.fromNumber-a.min)/d*100:0, 21 | b.fromX_pure=E/100*b.fromPers,b.fromX=Math.round(b.fromX_pure),m[0].style.left=b.fromX+"px",c.data(m[0],"x",b.fromX_pure),b.toPers=0!==d?(b.toNumber-a.min)/d*100:1,b.toX_pure=E/100*b.toPers,b.toX=Math.round(b.toX_pure),t[0].style.left=b.toX+"px",c.data(t[0],"x",b.toX_pure),K());f=b;da()},da=function(){var b,d,h,c,g,k;k=F/2;h="";"single"===a.type?(h=f.fromNumber===a.max?a.maxPostfix:"",a.hideText||(A[0].style.display="none",B[0].style.display="none",h=H?a.prefix+a.values[f.fromNumber]+h+a.postfix: 22 | a.prefix+z(f.fromNumber)+h+a.postfix,x.html(h),g=x.outerWidth(),k=f.fromX-g/2+k,0>k&&(k=0),k>l-g&&(k=l-g),x[0].style.left=k+"px",a.hideMinMax||a.hideFromTo||(C[0].style.display=kl-Q?"none":"block")),e.attr("value",parseFloat(f.fromNumber))):"double"===a.type&&(h=f.toNumber===a.max?a.maxPostfix:"",a.hideText||(H?(b=a.prefix+a.values[f.fromNumber]+a.postfix,d=a.prefix+a.values[f.toNumber]+h+a.postfix,h=f.fromNumber!==f.toNumber?a.prefix+a.values[f.fromNumber]+ 23 | " \u2014 "+a.prefix+a.values[f.toNumber]+h+a.postfix:a.prefix+a.values[f.fromNumber]+h+a.postfix):(b=a.prefix+z(f.fromNumber)+a.postfix,d=a.prefix+z(f.toNumber)+h+a.postfix,h=f.fromNumber!==f.toNumber?a.prefix+z(f.fromNumber)+" \u2014 "+a.prefix+z(f.toNumber)+h+a.postfix:a.prefix+z(f.fromNumber)+h+a.postfix),A.html(b),B.html(d),x.html(h),b=A.outerWidth(),d=f.fromX-b/2+k,0>d&&(d=0),d>l-b&&(d=l-b),A[0].style.left=d+"px",h=B.outerWidth(),c=f.toX-h/2+k,0>c&&(c=0),c>l-h&&(c=l-h),B[0].style.left=c+"px", 24 | g=x.outerWidth(),k=f.fromX+(f.toX-f.fromX)/2-g/2+k,0>k&&(k=0),k>l-g&&(k=l-g),x[0].style.left=k+"px",d+bl-Q||c+h>l-Q?"none":"block")),e.attr("value",parseFloat(f.fromNumber)+";"+parseFloat(f.toNumber)));"function"!==typeof a.onFinish||y||I|| 25 | a.onFinish.call(this,f);"function"!==typeof a.onChange||I||a.onChange.call(this,f);"function"===typeof a.onLoad&&!y&&I&&(a.onLoad.call(this,f),I=!1)},ha=function(){p.addClass("irs-with-grid");var b,d="",c=0,c=0,e="";for(b=0;20>=b;b+=1)c=Math.floor(l/20*b),c>=l&&(c=l-1),e+='';for(b=0;4>=b;b+=1)c=Math.floor(l/4*b),c>=l&&(c=l-1),e+='',u?(d=a.min+(a.max-a.min)/4*b,d=d/a.step*a.step, 26 | d=parseInt(d*u,10)/u):(d=Math.round(a.min+(a.max-a.min)/4*b),d=Math.round(d/a.step)*a.step,d=z(d)),H&&(a.hideMinMax?(d=Math.round(a.min+(a.max-a.min)/4*b),d=Math.round(d/a.step)*a.step,d=0===b||4===b?a.values[d]:""):d=""),0===b?e+=''+d+"":4===b?(c-=100,e+=''+d+""):(c-=50,e+=''+d+"");L.html(e)}; 27 | ba()}})},update:function(c){return this.each(function(){this.updateData(c)})},remove:function(){return this.each(function(){this.removeSlider()})}};c.fn.ionRangeSlider=function(s){if(G[s])return G[s].apply(this,Array.prototype.slice.call(arguments,1));if("object"!==typeof s&&s)c.error("Method "+s+" does not exist for jQuery.ionRangeSlider");else return G.init.apply(this,arguments)}})(jQuery,document,window,navigator); --------------------------------------------------------------------------------