From b8de75b1ef2f8a54a2e7a076388b02aa63a66f58 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Tue, 6 Jan 2026 02:39:56 +0500 Subject: [PATCH] Add "None" option to tags filter and update search functionality - Implemented apply_tags_filter method to handle "None" option in tag searches. - Updated tags select field to include "None" option in the filters. - Enhanced search_by_tag method in specs to accept multiple tags and raise an error if none are provided. - Added tests for searching by "None" tag and combinations with other tags. --- .../admin/products_v3_controller.rb | 46 +++++++++++++++++- .../admin/products_v3/_filters.html.haml | 2 +- config/locales/en.yml | 1 + spec/support/products_helper.rb | 8 +++- spec/support/request/tomselect_helper.rb | 2 +- spec/system/admin/products_v3/index_spec.rb | 47 ++++++++++++++++--- 6 files changed, 94 insertions(+), 12 deletions(-) diff --git a/app/controllers/admin/products_v3_controller.rb b/app/controllers/admin/products_v3_controller.rb index 19243b4278..db600e2c5b 100644 --- a/app/controllers/admin/products_v3_controller.rb +++ b/app/controllers/admin/products_v3_controller.rb @@ -179,6 +179,8 @@ module Admin product_query = OpenFoodNetwork::Permissions.new(spree_current_user) .editable_products.merge(product_scope_with_includes).ransack(ransack_query).result + product_query = apply_tags_filter(product_query) + # Postgres requires ORDER BY expressions to appear in the SELECT list when using DISTINCT. # When the current ransack sort uses the computed stock columns, include them in the select # so the generated COUNT/DISTINCT query is valid. @@ -225,12 +227,54 @@ module Admin query.merge!(Spree::Variant::SEARCH_KEY => @search_term) end query.merge!(variants_primary_taxon_id_in: @category_id) if @category_id.present? - query.merge!(variants_tags_name_in: @tags) if @tags.present? query.merge!(@q) if @q query end + # Apply tags filter with OR logic: + # - Products with variants having selected tags + # - OR products with variants having no tags (when "None" is selected) + # + # Note: This cannot be implemented using Ransack because Ransack applies + # AND semantics across associations and cannot express OR logic that combines + # the presence and absence of the same associated records. + def apply_tags_filter(base_query) + return base_query if @tags.blank? + + tags = Array(@tags) + none_key = I18n.t('admin.products_v3.filters.tags.none') + + has_none = tags.include?(none_key) + tag_names = tags.reject { |t| t == none_key } + + queries = [] + + if tag_names.any? + # Products with at least one variant having one of the selected tags + tagged_product_ids = Spree::Variant + .joins(taggings: :tag) + .where(tags: { name: tag_names }) + .select(:product_id) + + queries << base_query.where(id: tagged_product_ids) + end + + if has_none + # Products where no variants have any tags + tagged_product_ids = Spree::Variant + .joins(:taggings) + .select(:product_id) + + queries << base_query.where.not(id: tagged_product_ids) + end + + return base_query if queries.empty? + + # Combine queries using ActiveRecord's or method + queries.reduce { |combined, query| combined.or(query) } + end + # Optimise by pre-loading required columns def product_query_includes [ diff --git a/app/views/admin/products_v3/_filters.html.haml b/app/views/admin/products_v3/_filters.html.haml index fc07b83b46..a3e30e53b1 100644 --- a/app/views/admin/products_v3/_filters.html.haml +++ b/app/views/admin/products_v3/_filters.html.haml @@ -23,7 +23,7 @@ - select_tag_options = { class: "fullwidth", multiple: true , data: { controller: "tom-select", "tom-select-placeholder-value": t(".select_tag"), "tom-select-options-value": '{ "maxItems": 5 , "plugins": { "remove_button": {} , "no_active_items": {}, "checkbox_options": { "checkedClassNames": ["ts-checked"], "uncheckedClassNames": ["ts-unchecked"] } } }' } } - = select_tag :tags_name_in, options_for_select(available_tags, tags), select_tag_options + = select_tag :tags_name_in, options_for_select(available_tags.unshift(t('.tags.none')), tags), select_tag_options .submit .search-button = button_tag t(".search"), class: "secondary icon-search relaxed", name: nil diff --git a/config/locales/en.yml b/config/locales/en.yml index d3fe23123d..c79b9992a7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -977,6 +977,7 @@ en: label: Categories tags: label: Tags + none: None search: Search sort: pagination: diff --git a/spec/support/products_helper.rb b/spec/support/products_helper.rb index 42de042eb3..dde11158ce 100644 --- a/spec/support/products_helper.rb +++ b/spec/support/products_helper.rb @@ -34,8 +34,12 @@ module ProductsHelper click_button "Search" end - def search_by_tag(tag) - tomselect_multiselect tag, from: "tags_name_in" + def search_by_tag(*tags) + if tags.empty? + raise ArgumentError, "Please provide at least one tag to search for" + end + + tags.each { |tag| tomselect_multiselect tag, from: "tags_name_in" } click_button "Search" end diff --git a/spec/support/request/tomselect_helper.rb b/spec/support/request/tomselect_helper.rb index 9a6196a305..93b0717d8e 100644 --- a/spec/support/request/tomselect_helper.rb +++ b/spec/support/request/tomselect_helper.rb @@ -11,7 +11,7 @@ module TomselectHelper tomselect_wrapper.find(:css, '.ts-dropdown.multi .ts-dropdown-content .option', text: value).click # Close the dropdown - tomselect_wrapper.find(".ts-control").click + page.find("body").click end def tomselect_search_and_select(value, options) diff --git a/spec/system/admin/products_v3/index_spec.rb b/spec/system/admin/products_v3/index_spec.rb index ac56ec1cfb..d0f3d92337 100644 --- a/spec/system/admin/products_v3/index_spec.rb +++ b/spec/system/admin/products_v3/index_spec.rb @@ -417,16 +417,49 @@ RSpec.describe 'As an enterprise user, I can manage my products' do context "with variant tag", feature: :variant_tag do before do create(:variant, tag_list: "organic") - create_products 1 + create(:variant) # without tags + create(:variant) end - it "can search by tag" do - visit admin_products_url - search_by_tag "organic" + shared_examples "tag search" do + it description do + visit admin_products_url + search_by_tag(*search_tags) - expect(page).to have_select "tags_name_in", selected: "organic" - expect(page).to have_content "1 product found for your search criteria. Showing 1 to 1." - expect_products_count_to_be 1 + expect(page).to have_select("tags_name_in", selected: selected_tags) + expect(page).to have_content(result_text) + expect_products_count_to_be(expected_count) + end + end + + context "when searching by a single tag" do + let(:description) { "returns variants with that tag" } + let(:search_tags) { ["organic"] } + let(:selected_tags) { "organic" } + let(:expected_count) { 1 } + let(:result_text) { "1 product found for your search criteria. Showing 1 to 1." } + + include_examples "tag search" + end + + context "when searching by None tag" do + let(:description) { "returns variants without tags" } + let(:search_tags) { ["None"] } + let(:selected_tags) { "None" } + let(:expected_count) { 2 } + let(:result_text) { "2 products found for your search criteria. Showing 1 to 2." } + + include_examples "tag search" + end + + context "when searching by None and another tag" do + let(:description) { "returns variants with either no tags or the given tag" } + let(:search_tags) { ["None", "organic"] } + let(:selected_tags) { ["None", "organic"] } + let(:expected_count) { 3 } + let(:result_text) { "3 products found for your search criteria. Showing 1 to 3." } + + include_examples "tag search" end end end