Jessa
Jessa

Reputation: 1599

Filtering Products by an Option in Spree

With Spree Commerce 3.0-stable, I need to write a custom product filter to show only products where at least one variant matches the selected OptionValue.

I've got a filter that displays the correct list of options in the checkboxes, but selecting an option doesn't change which products are returned.

For this example, Products are available in multiple "Metal" options (platinum, white gold, yellow gold, silver, etc.). I've got the price range filter set up, and it's working correctly.

How can I get the products to filter by an Option?

My lib/spree/product_filters.rb

module Spree
  module Core
    module ProductFilters
      # Example: filtering by price
      #   The named scope just maps incoming labels onto their conditions, and builds the conjunction
      #   'price' is in the base scope's context (ie, "select foo from products where ...") so
      #     we can access the field right away
      #   The filter identifies which scope to use, then sets the conditions for each price range
      #
      # If user checks off three different price ranges then the argument passed to
      # below scope would be something like ["$10 - $15", "$15 - $18", "$18 - $20"]
      #
      Spree::Product.add_search_scope :price_range_any do |*opts|
        conds = opts.map {|o| Spree::Core::ProductFilters.price_filter[:conds][o]}.reject { |c| c.nil? }
        scope = conds.shift
        conds.each do |new_scope|
          scope = scope.or(new_scope)
        end
        Spree::Product.joins(master: :default_price).where(scope)
      end

      def ProductFilters.format_price(amount)
        Spree::Money.new(amount)
      end

      def ProductFilters.price_filter
        v = Spree::Price.arel_table
        conds = [ [ Spree.t(:under_price, price: format_price(1000))     , v[:amount].lteq(1000)],
                  [ "#{format_price(1000)} - #{format_price(1500)}"        , v[:amount].in(1000..1500)],
                  [ "#{format_price(1500)} - #{format_price(1800)}"        , v[:amount].in(1500..1800)],
                  [ "#{format_price(1800)} - #{format_price(2000)}"        , v[:amount].in(1800..2000)],
                  [ Spree.t(:or_over_price, price: format_price(2000)) , v[:amount].gteq(2000)]]
        {
          name:   Spree.t(:price_range),
          scope:  :price_range_any,
          conds:  Hash[*conds.flatten],
          labels: conds.map { |k,v| [k, k] }
        }
      end



      # Test for discrete option values selection
      def ProductFilters.option_with_values(option_scope, option, values)
        # get values IDs for Option with name {@option} and value-names in {@values} for use in SQL below
        option_values = Spree::OptionValue.where(:presentation => [values].flatten).joins(:option_type).where(OptionType.table_name => {:name => option}).pluck("#{OptionValue.table_name}.id")
        return option_scope if option_values.empty?

        option_scope = option_scope.where("#{Product.table_name}.id in (select product_id from #{Variant.table_name} v left join spree_option_values_variants ov on ov.variant_id = v.id where ov.option_value_id in (?))", option_values)
        option_scope
        puts option_scope.inspect
      end

      # multi-option scope
      Spree::Product.scope :option_any,
                         lambda { |*opts|
                           option_scope = Spree::Product.includes(:variants_including_master)
                           opts.map { |opt|
                             # opt is an array => ['option-name', [value1, value2, value3, ...]]
                             option_scope = option_with_values(option_scope, *opt)
                           }
                           option_scope
                         }

      # metal filter
      def ProductFilters.metal_filter
        metals = Spree::OptionValue.where( :option_type_id => Spree::OptionType.find_by!(name: "Metal") ).order("position").map(&:presentation).compact.uniq
        {
            :name => "Metal Type",
            :scope => :option_any,
            :conds => nil,
            :option => 'metal',
            :labels => metals.map { |k| [k, k] }
        }
      end

    end
  end
end

My app/views/spree/home/index.html.erb

<% content_for :sidebar do %>
  <div data-hook="homepage_sidebar_navigation">
    <%= render :partial => 'spree/shared/filters' %>
    <%= render :partial => 'spree/shared/taxonomies' %>
  </div>
<% end %>
<h2>Test!</h2>
<div data-hook="homepage_products">
  <% cache(cache_key_for_products) do %>
    <%= render :partial => 'spree/shared/products', :locals => { :products => @products } %>
  <% end %>
</div>

My app/views/spree/shared/_filters.html.erb

<% filters = [Spree::Core::ProductFilters.metal_filter,Spree::Core::ProductFilters.price_filter] %>

<% unless filters.empty? %>
  <%= form_tag '', :method => :get, :id => 'sidebar_products_search' do %>
    <%= hidden_field_tag 'per_page', params[:per_page] %>
    <% filters.each do |filter| %> <i><%= filter[:name] %> </i>
      <% labels = filter[:labels] || filter[:conds].map {|m,c| [m,m]} %>
      <% next if labels.empty? %>
      <div class="navigation" data-hook="navigation">
        <h4 class="filter-title"> <%= filter[:name] %> </h4>
        <ul class="list-group">
          <% labels.each do |nm,val| %>
            <% label = "#{filter[:name]}_#{nm}".gsub(/\s+/,'_') %>
            <li class="list-group-item">
              <input type="checkbox"
                     id="<%= label %>"
                     name="search[<%= filter[:scope].to_s %>][]"
                     value="<%= val %>"
                     <%= params[:search] && params[:search][filter[:scope]] && params[:search][filter[:scope]].include?(val.to_s) ? "checked" : "" %> />
              <label class="nowrap" for="<%= label %>"> <%= nm %> </label>
            </li>
          <% end %>
        </ul>
      </div>
    <% end %>
    <%= submit_tag Spree.t(:search), :name => nil, :class => 'btn btn-primary' %>
  <% end %>
<% end %>

Upvotes: 1

Views: 3131

Answers (2)

Tebbers
Tebbers

Reputation: 484

David Gross's answer above worked for me, although I am using the option of colour. Here's what my code looks like and the steps I took to get it to work.

1) Copy an unedited version of product_filters.rb to lib/product_filters.rb

2) Initialise it: in initializers/spree.rb, add in:

require 'product_filters'

# Spree.config do |config| etc........

3) Add this code to product_filters.rb:

  def ProductFilters.option_with_values(option_scope, option, values)
    # get values IDs for Option with name {@option} and value-names in {@values} for use in SQL below
    option_values = Spree::OptionValue.where(:presentation => [values].flatten).joins(:option_type).where(OptionType.table_name => {:name => option}).pluck("#{OptionValue.table_name}.id")
    return option_scope if option_values.empty?

    option_scope = option_scope.where("#{Product.table_name}.id in (select product_id from #{Variant.table_name} v left join spree_option_values_variants ov on ov.variant_id = v.id where ov.option_value_id in (?))", option_values)
    option_scope
  end

  # option scope
  Spree::Product.add_search_scope :option_any do |*opts|
    option_scope = Spree::Product.includes(:variants_including_master)
    option_type = ProductFilters.colour_filter[:option]

    opts.map { |opt|
      # opt is an array => ['option-name', [value1, value2, value3, ...]]
      option_scope = ProductFilters.option_with_values(option_scope, option_type, *opt)
     }
     option_scope
  end

  # colour option - object that describes the filter.
  def ProductFilters.colour_filter
    # Get an array of possible colours (option type of 'colour')
    # e.g. returns ["Gold", "Black", "White", "Silver", "Purple", "Multicoloured"]
    colours = Spree::OptionValue.where(:option_type_id => Spree::OptionType.find_by_name("colour")).order("position").map(&:presentation).compact.uniq
    {
        :name => "Colour",
        :scope => :option_any,
        :conds => nil,
        :option => 'colour', # this is MANDATORY
        :class => "colour",
        :labels => colours.map { |k| [k, k] }
    }
  end

4) Add your new filter to app/models/spree/taxons.rb so it appears on the front end:

def applicable_filters
  fs = []
  # fs << ProductFilters.taxons_below(self)
  ## unless it's a root taxon? left open for demo purposes

  fs << Spree::Core::ProductFilters.price_filter if Spree::Core::ProductFilters.respond_to?(:price_filter)
  fs << Spree::Core::ProductFilters.brand_filter if Spree::Core::ProductFilters.respond_to?(:brand_filter)
  fs << Spree::Core::ProductFilters.colour_filter if Spree::Core::ProductFilters.respond_to?(:colour_filter)
  fs
end

That should be it. I hope that helps - let me know if I can help further. Unfortunately the Spree filtering docs are nonexistent so we have to make do.

Upvotes: 3

David Gross
David Gross

Reputation: 1873

I dont know exactly what Spree::Product.scope is doing but try changing it to Spree::Product.add_search_scope. You are also missing an argument OptionType in option_with_values, you can use ProductFilters.metal_filter[:option].

Spree::Product.add_search_scope :option_any do |*opts|
  option_scope = Spree::Product.includes(:variants_including_master)
  option_type = ProductFilters.metal_filter[:option]

  opts.map { |opt|
    # opt is an array => ['option-name', [value1, value2, value3, ...]]
    option_scope = ProductFilters.option_with_values(option_scope, option_type, *opt)
   }
   option_scope
end

Upvotes: 1

Related Questions