From 062fcd317c5aae99b0fc3cb99ac08a9d2e61d02e Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Fri, 13 Mar 2026 02:14:41 +0500 Subject: [PATCH 1/6] Add searchable dropdowns for producers, categories, and tax categories in products_v3 --- .../admin/products_v3_controller.rb | 1 + .../concerns/products/ajax_search.rb | 58 +++++++++++++++++++ app/helpers/admin/products_helper.rb | 10 ++++ app/models/spree/ability.rb | 15 ++++- .../admin/products_v3/_filters.html.haml | 20 +++++-- .../admin/products_v3/_variant_row.html.haml | 12 ++-- .../controllers/tom_select_controller.js | 8 +-- config/routes/admin.rb | 3 + .../stimulus/tom_select_controller_test.js | 1 - 9 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 app/controllers/concerns/products/ajax_search.rb diff --git a/app/controllers/admin/products_v3_controller.rb b/app/controllers/admin/products_v3_controller.rb index 80b99aed73..ff3bc8d433 100644 --- a/app/controllers/admin/products_v3_controller.rb +++ b/app/controllers/admin/products_v3_controller.rb @@ -4,6 +4,7 @@ module Admin class ProductsV3Controller < Spree::Admin::BaseController helper ProductsHelper + include ::Products::AjaxSearch before_action :init_filters_params before_action :init_pagination_params diff --git a/app/controllers/concerns/products/ajax_search.rb b/app/controllers/concerns/products/ajax_search.rb new file mode 100644 index 0000000000..37cec78e0a --- /dev/null +++ b/app/controllers/concerns/products/ajax_search.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Products + module AjaxSearch + extend ActiveSupport::Concern + + def search_producers + query = OpenFoodNetwork::Permissions.new(spree_current_user) + .managed_product_enterprises.is_primary_producer.by_name + + render json: build_search_response(query) + end + + def search_categories + query = Spree::Taxon.all + + render json: build_search_response(query) + end + + def search_tax_categories + query = Spree::TaxCategory.all + + render json: build_search_response(query) + end + + private + + def build_search_response(query) + page = (params[:page] || 1).to_i + per_page = 30 + + filtered_query = apply_search_filter(query) + total_count = filtered_query.size + items = paginated_items(filtered_query, page, per_page) + results = format_results(items) + + { results: results, pagination: { more: (page * per_page) < total_count } } + end + + def apply_search_filter(query) + search_term = params[:q] + return query if search_term.blank? + + escaped_search_term = ActiveRecord::Base.sanitize_sql_like(search_term) + pattern = "%#{escaped_search_term}%" + + query.where('name ILIKE ?', pattern) + end + + def paginated_items(query, page, per_page) + query.order(:name).offset((page - 1) * per_page).limit(per_page).pluck(:name, :id) + end + + def format_results(items) + items.map { |label, value| { value:, label: } } + end + end +end diff --git a/app/helpers/admin/products_helper.rb b/app/helpers/admin/products_helper.rb index 8b07a650d9..1b10ea9330 100644 --- a/app/helpers/admin/products_helper.rb +++ b/app/helpers/admin/products_helper.rb @@ -52,5 +52,15 @@ module Admin @allowed_source_producers ||= OpenFoodNetwork::Permissions.new(spree_current_user) .enterprises_granting_linked_variants end + + # Query only name of the model to avoid loading the whole record + def selected_option(id, model) + return [] unless id + + name = model.where(id: id).pick(:name) + return [] unless name + + [[name, id]] + end end end diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index c8e4e19831..0de27a0718 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -221,9 +221,18 @@ module Spree OpenFoodNetwork::Permissions.new(user). enterprises_granting_linked_variants.include? variant.supplier end - - can [:admin, :index, :bulk_update, :destroy, :destroy_variant, :clone, - :create_linked_variant], :products_v3 + can [ + :admin, + :index, + :bulk_update, + :destroy, + :destroy_variant, + :clone, + :create_linked_variant, + :search_producers, + :search_categories, + :search_tax_categories + ], :products_v3 can [:create], Spree::Variant can [:admin, :index, :read, :edit, diff --git a/app/views/admin/products_v3/_filters.html.haml b/app/views/admin/products_v3/_filters.html.haml index 68e0c88ebb..93e60182f8 100644 --- a/app/views/admin/products_v3/_filters.html.haml +++ b/app/views/admin/products_v3/_filters.html.haml @@ -9,14 +9,22 @@ - if producer_options.many? .producers = label_tag :producer_id, t('.producers.label') - = select_tag :producer_id, options_for_select(producer_options, producer_id), - include_blank: t('.all_producers'), class: "fullwidth", - data: { "controller": "tom-select", 'tom-select-placeholder-value': t('.search_for_producers')} + = render(SearchableDropdownComponent.new(name: :producer_id, + aria_label: t('.producer_field_name'), + options: selected_option(producer_id, Enterprise), + selected_option: producer_id, + remote_url: admin_products_search_producers_url, + include_blank: t('.all_producers'), + placeholder_value: t('.search_for_producers'))) .categories = label_tag :category_id, t('.categories.label') - = select_tag :category_id, options_for_select(category_options, category_id), - include_blank: t('.all_categories'), class: "fullwidth", - data: { "controller": "tom-select", 'tom-select-placeholder-value': t('.search_for_categories')} + = render(SearchableDropdownComponent.new(name: :category_id, + aria_label: t('.category_field_name'), + options: selected_option(category_id, Spree::Taxon), + selected_option: category_id, + remote_url: admin_products_search_categories_url, + include_blank: t('.all_categories'), + placeholder_value: t('.search_for_categories'))) -if variant_tag_enabled?(spree_current_user) .tags = label_tag :tags_name_in, t('.tags.label') diff --git a/app/views/admin/products_v3/_variant_row.html.haml b/app/views/admin/products_v3/_variant_row.html.haml index cd9e8ae6ee..534c39b8e3 100644 --- a/app/views/admin/products_v3/_variant_row.html.haml +++ b/app/views/admin/products_v3/_variant_row.html.haml @@ -59,27 +59,27 @@ = render(SearchableDropdownComponent.new(form: f, name: :supplier_id, aria_label: t('.producer_field_name'), - options: producer_options, + options: variant.supplier_id ? [[variant.supplier.name, variant.supplier_id]] : [], selected_option: variant.supplier_id, - include_blank: t('admin.products_v3.filters.select_producer'), + remote_url: admin_products_search_producers_url, placeholder_value: t('admin.products_v3.filters.select_producer'))) = error_message_on variant, :supplier %td.col-category.field.naked_inputs = render(SearchableDropdownComponent.new(form: f, name: :primary_taxon_id, - options: category_options, + options: variant.primary_taxon_id ? [[variant.primary_taxon.name, variant.primary_taxon_id]] : [], selected_option: variant.primary_taxon_id, aria_label: t('.category_field_name'), - include_blank: t('admin.products_v3.filters.select_category'), + remote_url: admin_products_search_categories_url, placeholder_value: t('admin.products_v3.filters.select_category'))) = error_message_on variant, :primary_taxon %td.col-tax_category.field.naked_inputs = render(SearchableDropdownComponent.new(form: f, name: :tax_category_id, - options: tax_category_options, + options: variant.tax_category_id ? [[variant.tax_category.name, variant.tax_category_id]] : [], selected_option: variant.tax_category_id, - include_blank: t('.none_tax_category'), aria_label: t('.tax_category_field_name'), + remote_url: admin_products_search_tax_categories_url, placeholder_value: t('.search_for_tax_categories'))) = error_message_on variant, :tax_category - if variant_tag_enabled?(spree_current_user) diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js index 3867fa3380..203f3ce2b2 100644 --- a/app/webpacker/controllers/tom_select_controller.js +++ b/app/webpacker/controllers/tom_select_controller.js @@ -83,6 +83,9 @@ export default class extends Controller { } #addRemoteOptions(options) { + // by default, for dropdown_input plugin, it's true. Otherwise for multi-select it's false + // it should always be true so to invoke the onDropdownOpen to fetch options + options.shouldOpen = true; this.openedByClick = false; options.firstUrl = (query) => { @@ -91,12 +94,9 @@ export default class extends Controller { options.load = this.#fetchOptions.bind(this); - options.onFocus = function () { - this.control.load("", () => {}); - }.bind(this); - options.onDropdownOpen = function () { this.openedByClick = true; + this.control.load("", () => {}); }.bind(this); options.onType = function () { diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 33553f5a51..bfc9434a41 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -83,6 +83,9 @@ Openfoodnetwork::Application.routes.draw do delete 'products_v3/destroy_variant/:id', to: 'products_v3#destroy_variant', as: 'destroy_variant' post 'clone/:id', to: 'products_v3#clone', as: 'clone_product' post 'products/create_linked_variant', to: 'products_v3#create_linked_variant', as: 'create_linked_variant' + get 'products_v3/search_producers', to: 'products_v3#search_producers', as: 'products_search_producers' + get 'products_v3/search_categories', to: 'products_v3#search_categories', as: 'products_search_categories' + get 'products_v3/search_tax_categories', to: 'products_v3#search_tax_categories', as: 'products_search_tax_categories' resources :product_preview, only: [:show] resources :variant_overrides do diff --git a/spec/javascripts/stimulus/tom_select_controller_test.js b/spec/javascripts/stimulus/tom_select_controller_test.js index 4f4705e222..c1293e2eb2 100644 --- a/spec/javascripts/stimulus/tom_select_controller_test.js +++ b/spec/javascripts/stimulus/tom_select_controller_test.js @@ -166,7 +166,6 @@ describe("TomSelectController", () => { expect(settings.searchField).toBe("label"); expect(settings.load).toEqual(expect.any(Function)); expect(settings.firstUrl).toEqual(expect.any(Function)); - expect(settings.onFocus).toEqual(expect.any(Function)); }); it("fetches page 1 on focus", async () => { From 044f6131da64bd4f0cce8fe0231945570a36f668 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Thu, 19 Mar 2026 02:38:17 +0500 Subject: [PATCH 2/6] fix aria_label translations --- app/views/admin/products_v3/_filters.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/admin/products_v3/_filters.html.haml b/app/views/admin/products_v3/_filters.html.haml index 93e60182f8..bf82a02c72 100644 --- a/app/views/admin/products_v3/_filters.html.haml +++ b/app/views/admin/products_v3/_filters.html.haml @@ -10,7 +10,7 @@ .producers = label_tag :producer_id, t('.producers.label') = render(SearchableDropdownComponent.new(name: :producer_id, - aria_label: t('.producer_field_name'), + aria_label: t('.producers.label'), options: selected_option(producer_id, Enterprise), selected_option: producer_id, remote_url: admin_products_search_producers_url, @@ -19,7 +19,7 @@ .categories = label_tag :category_id, t('.categories.label') = render(SearchableDropdownComponent.new(name: :category_id, - aria_label: t('.category_field_name'), + aria_label: t('.categories.label'), options: selected_option(category_id, Spree::Taxon), selected_option: category_id, remote_url: admin_products_search_categories_url, From 8ba0ab6b5a2d32d9957c158ec83f77d8b5a74479 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Wed, 25 Mar 2026 02:01:36 +0500 Subject: [PATCH 3/6] Update specs according to new remote search function on products page --- .../admin/products_v3/_variant_row.html.haml | 1 + spec/support/tom_select_helper.rb | 63 ++++++++++++++++++- spec/system/admin/products_v3/actions_spec.rb | 63 +++---------------- spec/system/admin/products_v3/create_spec.rb | 6 +- spec/system/admin/products_v3/index_spec.rb | 35 ++++++----- spec/system/admin/products_v3/update_spec.rb | 8 +-- .../products_v3/_filters.html.haml_spec.rb | 6 +- 7 files changed, 99 insertions(+), 83 deletions(-) diff --git a/app/views/admin/products_v3/_variant_row.html.haml b/app/views/admin/products_v3/_variant_row.html.haml index 534c39b8e3..7bc193f1e4 100644 --- a/app/views/admin/products_v3/_variant_row.html.haml +++ b/app/views/admin/products_v3/_variant_row.html.haml @@ -79,6 +79,7 @@ options: variant.tax_category_id ? [[variant.tax_category.name, variant.tax_category_id]] : [], selected_option: variant.tax_category_id, aria_label: t('.tax_category_field_name'), + include_blank: t('.none_tax_category'), remote_url: admin_products_search_tax_categories_url, placeholder_value: t('.search_for_tax_categories'))) = error_message_on variant, :tax_category diff --git a/spec/support/tom_select_helper.rb b/spec/support/tom_select_helper.rb index 34a0179d98..957f65f7f2 100644 --- a/spec/support/tom_select_helper.rb +++ b/spec/support/tom_select_helper.rb @@ -19,12 +19,25 @@ module TomSelectHelper tomselect_wrapper.find(:css, '.ts-dropdown div.create').click end + # Searches for and selects an option in a TomSelect dropdown with search functionality. + # @param value [String] The text to search for and select from the dropdown + # @param options [Hash] Configuration options + # @option options [String] :from The name/id of the select field + # @option options [Boolean] :remote_search If true, waits for search loading after interactions + # + # @example + # tomselect_search_and_select("Apple", from: "fruit_selector") + # tomselect_search_and_select("California", from: "state", remote_search: true) def tomselect_search_and_select(value, options) tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper") tomselect_wrapper.find(".ts-control").click + expect_tomselect_loading_completion(tomselect_wrapper, options) + # Use send_keys as setting the value directly doesn't trigger the search tomselect_wrapper.find(".ts-dropdown input.dropdown-input").send_keys(value) - tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option.active", text: value).click + expect_tomselect_loading_completion(tomselect_wrapper, options) + + tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option", text: value).click end def tomselect_select(value, options) @@ -64,4 +77,52 @@ module TomSelectHelper end end end + + # Validates both available options and selected options in a TomSelect dropdown. + # @param from [String] The name/id of the select field + # @param existing_options [Array] List of options that should be available in the dropdown + # @param selected_options [Array] List of options that should currently be selected + # + # @example + # expect_tomselect_existing_with_selected_options( + # from: "category_selector", + # existing_options: ["Fruit", "Vegetables", "Dairy"], + # selected_options: ["Fruit"] + # ) + def expect_tomselect_existing_with_selected_options(from:, existing_options:, selected_options:) + tomselect_wrapper = page.find_field(from).sibling(".ts-wrapper") + tomselect_control = tomselect_wrapper.find('.ts-control') + + tomselect_control.click # open the dropdown (would work for remote vs non-remote dropdowns) + + # validate existing options are present in the dropdown + within(tomselect_wrapper) do + existing_options.each do |option| + expect(page).to have_css( + ".ts-dropdown .ts-dropdown-content .option", + text: option + ) + end + end + + # validate selected options are selected in the dropdown + within(tomselect_wrapper) do + selected_options.each do |option| + expect(page).to have_css( + "div[data-ts-item]", + text: option + ) + end + end + + # close the dropdown by clicking on the already selected option + tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option.active").click + end + + def expect_tomselect_loading_completion(tomselect_wrapper, options) + return unless options[:remote_search] + + expect(tomselect_wrapper).to have_css(".spinner") + expect(tomselect_wrapper).not_to have_css(".spinner") + end end diff --git a/spec/system/admin/products_v3/actions_spec.rb b/spec/system/admin/products_v3/actions_spec.rb index 16650bd588..897775990a 100644 --- a/spec/system/admin/products_v3/actions_spec.rb +++ b/spec/system/admin/products_v3/actions_spec.rb @@ -15,10 +15,6 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr login_as user end - let(:producer_search_selector) { 'input[placeholder="Select producer"]' } - let(:categories_search_selector) { 'input[placeholder="Select category"]' } - let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' } - describe "column selector" do let!(:product) { create(:simple_product) } @@ -102,54 +98,7 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr } let!(:product_a) { create(:simple_product, name: "Apples", sku: "APL-00") } - context "when they are under 11" do - before do - create_list(:supplier_enterprise, 9, users: [user]) - create_list(:tax_category, 9) - create_list(:taxon, 2) - - visit admin_products_url - end - - it "should not display search input, change the producers, category and tax category" do - producer_to_select = random_producer(variant_a1) - category_to_select = random_category(variant_a1) - tax_category_to_select = random_tax_category - - within row_containing_name(variant_a1.display_name) do - validate_tomselect_without_search!( - page, "Producer", - producer_search_selector - ) - tomselect_select(producer_to_select, from: "Producer") - end - - within row_containing_name(variant_a1.display_name) do - validate_tomselect_without_search!( - page, "Category", - categories_search_selector - ) - tomselect_select(category_to_select, from: "Category") - - validate_tomselect_without_search!( - page, "Tax Category", - tax_categories_search_selector - ) - tomselect_select(tax_category_to_select, from: "Tax Category") - end - - click_button "Save changes" - - expect(page).to have_content "Changes saved" - - variant_a1.reload - expect(variant_a1.supplier.name).to eq(producer_to_select) - expect(variant_a1.primary_taxon.name).to eq(category_to_select) - expect(variant_a1.tax_category.name).to eq(tax_category_to_select) - end - end - - context "when they are over 11" do + context "when there are products" do before do create_list(:supplier_enterprise, 11, users: [user]) create_list(:tax_category, 11) @@ -167,9 +116,13 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr tax_category_to_select = random_tax_category within row_containing_name(variant_a1.display_name) do - tomselect_search_and_select(producer_to_select, from: "Producer") - tomselect_search_and_select(category_to_select, from: "Category") - tomselect_search_and_select(tax_category_to_select, from: "Tax Category") + tomselect_search_and_select(producer_to_select, from: "Producer", remote_search: true) + tomselect_search_and_select(category_to_select, from: "Category", remote_search: true) + tomselect_search_and_select( + tax_category_to_select, + from: "Tax Category", + remote_search: true + ) end click_button "Save changes" diff --git a/spec/system/admin/products_v3/create_spec.rb b/spec/system/admin/products_v3/create_spec.rb index 965d9f9e99..04f90abcaf 100644 --- a/spec/system/admin/products_v3/create_spec.rb +++ b/spec/system/admin/products_v3/create_spec.rb @@ -86,14 +86,14 @@ RSpec.describe 'As an enterprise user, I can manage my products' do find('button[aria-label="On Hand"]').click find('input[id$="_price"]').fill_in with: "11.1" - select supplier.name, from: 'Producer' - select taxon.name, from: 'Category' - if stock == "on_hand" find('input[id$="_on_hand_desired"]').fill_in with: "66" elsif stock == "on_demand" find('input[id$="_on_demand_desired"]').check end + + tomselect_select supplier.name, from: 'Producer' + tomselect_select taxon.name, from: 'Category' end expect(page).to have_content "1 product modified." diff --git a/spec/system/admin/products_v3/index_spec.rb b/spec/system/admin/products_v3/index_spec.rb index d943e5a1d3..d53a391e0c 100644 --- a/spec/system/admin/products_v3/index_spec.rb +++ b/spec/system/admin/products_v3/index_spec.rb @@ -106,13 +106,19 @@ RSpec.describe 'As an enterprise user, I can browse my products' do visit spree.admin_products_path within row_containing_name "Variant1" do - expect(page).to have_select "Producer", with_options: ["Producer A", "Producer B"], - selected: "Producer A" + expect_tomselect_existing_with_selected_options( + from: 'Producer', + existing_options: ["Producer A", "Producer B"], + selected_options: ["Producer A"] + ) end within row_containing_name "Variant2a" do - expect(page).to have_select "Producer", with_options: ["Producer A", "Producer B"], - selected: "Producer B" + expect_tomselect_existing_with_selected_options( + from: 'Producer', + existing_options: ["Producer A", "Producer B"], + selected_options: ["Producer B"] + ) end end end @@ -543,24 +549,21 @@ RSpec.describe 'As an enterprise user, I can browse my products' do it "shows only suppliers that I manage or have permission to" do visit spree.admin_products_path + existing_options = [supplier_managed1.name, supplier_managed2.name, supplier_permitted.name] within row_containing_placeholder(product_supplied.name) do - expect(page).to have_select( - '_products_0_variants_attributes_0_supplier_id', - options: [ - 'Select producer', - supplier_managed1.name, supplier_managed2.name, supplier_permitted.name - ], selected: supplier_managed1.name + expect_tomselect_existing_with_selected_options( + existing_options:, + from: '_products_0_variants_attributes_0_supplier_id', + selected_options: [supplier_managed1.name] ) end within row_containing_placeholder(product_supplied_permitted.name) do - expect(page).to have_select( - '_products_1_variants_attributes_0_supplier_id', - options: [ - 'Select producer', - supplier_managed1.name, supplier_managed2.name, supplier_permitted.name - ], selected: supplier_permitted.name + expect_tomselect_existing_with_selected_options( + existing_options:, + from: '_products_1_variants_attributes_0_supplier_id', + selected_options: [supplier_permitted.name] ) end end diff --git a/spec/system/admin/products_v3/update_spec.rb b/spec/system/admin/products_v3/update_spec.rb index 0da94594f6..4ddeff1578 100644 --- a/spec/system/admin/products_v3/update_spec.rb +++ b/spec/system/admin/products_v3/update_spec.rb @@ -350,8 +350,8 @@ RSpec.describe 'As an enterprise user, I can update my products' do click_on "On Hand" # activate popout fill_in "On Hand", with: "3" - select producer.name, from: 'Producer' - select taxon.name, from: 'Category' + tomselect_select producer.name, from: 'Producer' + tomselect_select taxon.name, from: 'Category' end expect { @@ -586,8 +586,8 @@ RSpec.describe 'As an enterprise user, I can update my products' do fill_in "Name", with: "Nice box" fill_in "SKU", with: "APL-02" - select producer.name, from: 'Producer' - select taxon.name, from: 'Category' + tomselect_select producer.name, from: 'Producer' + tomselect_select taxon.name, from: 'Category' end expect { diff --git a/spec/views/admin/products_v3/_filters.html.haml_spec.rb b/spec/views/admin/products_v3/_filters.html.haml_spec.rb index a6bcbfbca3..1b2ed5b697 100644 --- a/spec/views/admin/products_v3/_filters.html.haml_spec.rb +++ b/spec/views/admin/products_v3/_filters.html.haml_spec.rb @@ -17,7 +17,7 @@ RSpec.describe "admin/products_v3/_filters.html.haml" do end let(:spree_current_user) { build(:enterprise_user) } - it "shows the producer filter when there are options" do + it "shows the producer filter with the default option initially" do allow(view).to receive_messages locals.merge( producer_options: [ ["Ada's Apples", 1], @@ -27,9 +27,7 @@ RSpec.describe "admin/products_v3/_filters.html.haml" do is_expected.to have_content "Producers" is_expected.to have_select "producer_id", options: [ - "All producers", - "Ada's Apples", - "Ben's Bananas", + "All producers" ], selected: nil end From 3ee338fa8d131aded18e7372dc96071237c1519d Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Tue, 31 Mar 2026 01:54:02 +0500 Subject: [PATCH 4/6] Add ajax search controller --- .../ajax_search_controller.rb} | 12 +- .../admin/products_v3_controller.rb | 1 - app/models/spree/ability.rb | 7 +- .../admin/products_v3/_filters.html.haml | 4 +- .../admin/products_v3/_variant_row.html.haml | 6 +- bin/setup | 2 +- config/routes/admin.rb | 10 +- .../admin/ajax_search_controller_spec.rb | 285 ++++++++++++++++++ 8 files changed, 306 insertions(+), 21 deletions(-) rename app/controllers/{concerns/products/ajax_search.rb => admin/ajax_search_controller.rb} (89%) create mode 100644 spec/requests/admin/ajax_search_controller_spec.rb diff --git a/app/controllers/concerns/products/ajax_search.rb b/app/controllers/admin/ajax_search_controller.rb similarity index 89% rename from app/controllers/concerns/products/ajax_search.rb rename to app/controllers/admin/ajax_search_controller.rb index 37cec78e0a..108037873f 100644 --- a/app/controllers/concerns/products/ajax_search.rb +++ b/app/controllers/admin/ajax_search_controller.rb @@ -1,23 +1,21 @@ # frozen_string_literal: true -module Products - module AjaxSearch - extend ActiveSupport::Concern - - def search_producers +module Admin + class AjaxSearchController < Spree::Admin::BaseController + def producers query = OpenFoodNetwork::Permissions.new(spree_current_user) .managed_product_enterprises.is_primary_producer.by_name render json: build_search_response(query) end - def search_categories + def categories query = Spree::Taxon.all render json: build_search_response(query) end - def search_tax_categories + def tax_categories query = Spree::TaxCategory.all render json: build_search_response(query) diff --git a/app/controllers/admin/products_v3_controller.rb b/app/controllers/admin/products_v3_controller.rb index ff3bc8d433..80b99aed73 100644 --- a/app/controllers/admin/products_v3_controller.rb +++ b/app/controllers/admin/products_v3_controller.rb @@ -4,7 +4,6 @@ module Admin class ProductsV3Controller < Spree::Admin::BaseController helper ProductsHelper - include ::Products::AjaxSearch before_action :init_filters_params before_action :init_pagination_params diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index 0de27a0718..8a41422713 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -228,12 +228,11 @@ module Spree :destroy, :destroy_variant, :clone, - :create_linked_variant, - :search_producers, - :search_categories, - :search_tax_categories + :create_linked_variant ], :products_v3 + can [:admin, :producers, :categories, :tax_categories], :ajax_search + can [:create], Spree::Variant can [:admin, :index, :read, :edit, :update, :search, :delete, :destroy], Spree::Variant do |variant| diff --git a/app/views/admin/products_v3/_filters.html.haml b/app/views/admin/products_v3/_filters.html.haml index bf82a02c72..96ace78b82 100644 --- a/app/views/admin/products_v3/_filters.html.haml +++ b/app/views/admin/products_v3/_filters.html.haml @@ -13,7 +13,7 @@ aria_label: t('.producers.label'), options: selected_option(producer_id, Enterprise), selected_option: producer_id, - remote_url: admin_products_search_producers_url, + remote_url: admin_ajax_search_producers_url, include_blank: t('.all_producers'), placeholder_value: t('.search_for_producers'))) .categories @@ -22,7 +22,7 @@ aria_label: t('.categories.label'), options: selected_option(category_id, Spree::Taxon), selected_option: category_id, - remote_url: admin_products_search_categories_url, + remote_url: admin_ajax_search_categories_url, include_blank: t('.all_categories'), placeholder_value: t('.search_for_categories'))) -if variant_tag_enabled?(spree_current_user) diff --git a/app/views/admin/products_v3/_variant_row.html.haml b/app/views/admin/products_v3/_variant_row.html.haml index 7bc193f1e4..139e2bef7f 100644 --- a/app/views/admin/products_v3/_variant_row.html.haml +++ b/app/views/admin/products_v3/_variant_row.html.haml @@ -61,7 +61,7 @@ aria_label: t('.producer_field_name'), options: variant.supplier_id ? [[variant.supplier.name, variant.supplier_id]] : [], selected_option: variant.supplier_id, - remote_url: admin_products_search_producers_url, + remote_url: admin_ajax_search_producers_url, placeholder_value: t('admin.products_v3.filters.select_producer'))) = error_message_on variant, :supplier %td.col-category.field.naked_inputs @@ -70,7 +70,7 @@ options: variant.primary_taxon_id ? [[variant.primary_taxon.name, variant.primary_taxon_id]] : [], selected_option: variant.primary_taxon_id, aria_label: t('.category_field_name'), - remote_url: admin_products_search_categories_url, + remote_url: admin_ajax_search_categories_url, placeholder_value: t('admin.products_v3.filters.select_category'))) = error_message_on variant, :primary_taxon %td.col-tax_category.field.naked_inputs @@ -80,7 +80,7 @@ selected_option: variant.tax_category_id, aria_label: t('.tax_category_field_name'), include_blank: t('.none_tax_category'), - remote_url: admin_products_search_tax_categories_url, + remote_url: admin_ajax_search_tax_categories_url, placeholder_value: t('.search_for_tax_categories'))) = error_message_on variant, :tax_category - if variant_tag_enabled?(spree_current_user) diff --git a/bin/setup b/bin/setup index a55bfa66b9..f1e5fa0be5 100755 --- a/bin/setup +++ b/bin/setup @@ -27,7 +27,7 @@ FileUtils.chdir APP_ROOT do system("bundle check 2> /dev/null") || system!(BUNDLE_ENV, "bundle install") # Install JavaScript dependencies - system!("script/nodenv-install.sh") + system("script/nodenv-install.sh") system!("bin/yarn") # puts "\n== Copying sample files ==" diff --git a/config/routes/admin.rb b/config/routes/admin.rb index bfc9434a41..dd46ab70c0 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -83,9 +83,13 @@ Openfoodnetwork::Application.routes.draw do delete 'products_v3/destroy_variant/:id', to: 'products_v3#destroy_variant', as: 'destroy_variant' post 'clone/:id', to: 'products_v3#clone', as: 'clone_product' post 'products/create_linked_variant', to: 'products_v3#create_linked_variant', as: 'create_linked_variant' - get 'products_v3/search_producers', to: 'products_v3#search_producers', as: 'products_search_producers' - get 'products_v3/search_categories', to: 'products_v3#search_categories', as: 'products_search_categories' - get 'products_v3/search_tax_categories', to: 'products_v3#search_tax_categories', as: 'products_search_tax_categories' + + scope :ajax_search, as: :ajax_search, controller: :ajax_search do + get :producers + get :categories + get :tax_categories + end + resources :product_preview, only: [:show] resources :variant_overrides do diff --git a/spec/requests/admin/ajax_search_controller_spec.rb b/spec/requests/admin/ajax_search_controller_spec.rb new file mode 100644 index 0000000000..b23f59efb4 --- /dev/null +++ b/spec/requests/admin/ajax_search_controller_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +RSpec.describe "Admin::AjaxSearch" do + include AuthenticationHelper + + let(:admin_user) { create(:admin_user) } + let(:regular_user) { create(:user) } + + describe "GET /admin/ajax_search/producers" do + context "when user is not logged in" do + it "redirects to login" do + get admin_ajax_search_producers_path + + expect(response).to redirect_to %r|#/login$| + end + end + + context "when user is logged in without permissions" do + before { login_as regular_user } + + it "redirects to unauthorized" do + get admin_ajax_search_producers_path + + expect(response).to redirect_to('/unauthorized') + end + end + + context "when user is an admin" do + before { login_as admin_user } + + let!(:producer1) { create(:supplier_enterprise, name: "Apple Farm") } + let!(:producer2) { create(:supplier_enterprise, name: "Berry Farm") } + let!(:producer3) { create(:supplier_enterprise, name: "Cherry Orchard") } + let!(:distributor) { create(:distributor_enterprise, name: "Distributor") } + + it "returns producers sorted alphabetically by name" do + get admin_ajax_search_producers_path + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + expect(json_response["results"].pluck("label")).to eq(['Apple Farm', 'Berry Farm', + 'Cherry Orchard']) + expect(json_response["pagination"]["more"]).to be false + end + + it "filters producers by search query" do + get admin_ajax_search_producers_path, params: { q: "berry" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['Berry Farm']) + expect(json_response["results"].pluck("value")).to eq([producer2.id]) + end + + it "filters are case insensitive" do + get admin_ajax_search_producers_path, params: { q: "BERRY" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['Berry Farm']) + end + + it "filters with partial matches" do + get admin_ajax_search_producers_path, params: { q: "Farm" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['Apple Farm', 'Berry Farm']) + end + + it "excludes non-producer enterprises" do + get admin_ajax_search_producers_path + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).not_to include('Distributor') + end + + context "with more than 30 producers" do + before do + create_list(:supplier_enterprise, 35) do |enterprise, i| + enterprise.update!(name: "Producer #{(i + 1).to_s.rjust(2, '0')}") + end + end + + it "returns first page with 30 results and more flag as true" do + get admin_ajax_search_producers_path, params: { page: 1 } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + end + + it "returns remaining results on second page with more flag as false" do + get admin_ajax_search_producers_path, params: { page: 2 } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(8) + expect(json_response["pagination"]["more"]).to be false + end + end + end + + context "when user has enterprise permissions" do + let!(:my_producer) { create(:supplier_enterprise, name: "My Producer") } + let!(:other_producer) { create(:supplier_enterprise, name: "Other Producer") } + let(:user_with_producer) { create(:user, enterprises: [my_producer]) } + + before { login_as user_with_producer } + + it "returns only managed producers" do + get admin_ajax_search_producers_path + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['My Producer']) + expect(json_response["results"].pluck("label")).not_to include('Other Producer') + end + end + end + + describe "GET /admin/ajax_search/categories" do + context "when user is not logged in" do + it "redirects to login" do + get admin_ajax_search_categories_path + + expect(response).to redirect_to %r|#/login$| + end + end + + context "when user is logged in without permissions" do + before { login_as regular_user } + + it "redirects to unauthorized" do + get admin_ajax_search_categories_path + + expect(response).to redirect_to('/unauthorized') + end + end + + context "when user is an admin" do + before { login_as admin_user } + + let!(:category1) { create(:taxon, name: "Vegetables") } + let!(:category2) { create(:taxon, name: "Fruits") } + let!(:category3) { create(:taxon, name: "Dairy") } + + it "returns categories sorted alphabetically by name" do + get admin_ajax_search_categories_path + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + expect(json_response["results"].pluck("label")).to eq(['Dairy', 'Fruits', 'Vegetables']) + expect(json_response["pagination"]["more"]).to be false + end + + it "filters categories by search query" do + get admin_ajax_search_categories_path, params: { q: "fruit" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['Fruits']) + expect(json_response["results"].pluck("value")).to eq([category2.id]) + end + + it "filters are case insensitive" do + get admin_ajax_search_categories_path, params: { q: "VEGETABLES" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['Vegetables']) + end + + it "filters with partial matches" do + get admin_ajax_search_categories_path, params: { q: "ege" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['Vegetables']) + end + + context "with more than 30 categories" do + before do + create_list(:taxon, 35) do |taxon, i| + taxon.update!(name: "Category #{(i + 1).to_s.rjust(2, '0')}") + end + end + + it "returns first page with 30 results and more flag as true" do + get admin_ajax_search_categories_path, params: { page: 1 } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + end + + it "returns remaining results on second page with more flag as false" do + get admin_ajax_search_categories_path, params: { page: 2 } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(8) + expect(json_response["pagination"]["more"]).to be false + end + end + end + end + + describe "GET /admin/ajax_search/tax_categories" do + context "when user is not logged in" do + it "redirects to login" do + get admin_ajax_search_tax_categories_path + + expect(response).to redirect_to %r|#/login$| + end + end + + context "when user is logged in without permissions" do + before { login_as regular_user } + + it "redirects to unauthorized" do + get admin_ajax_search_tax_categories_path + + expect(response).to redirect_to('/unauthorized') + end + end + + context "when user is an admin" do + before { login_as admin_user } + + let!(:tax_cat1) { create(:tax_category, name: "GST") } + let!(:tax_cat2) { create(:tax_category, name: "VAT") } + let!(:tax_cat3) { create(:tax_category, name: "No Tax") } + + it "returns tax categories sorted alphabetically by name" do + get admin_ajax_search_tax_categories_path + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + expect(json_response["results"].pluck("label")).to eq(['GST', 'No Tax', 'VAT']) + expect(json_response["pagination"]["more"]).to be false + end + + it "filters tax categories by search query" do + get admin_ajax_search_tax_categories_path, params: { q: "vat" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['VAT']) + expect(json_response["results"].pluck("value")).to eq([tax_cat2.id]) + end + + it "filters are case insensitive" do + get admin_ajax_search_tax_categories_path, params: { q: "GST" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['GST']) + end + + it "filters with partial matches" do + get admin_ajax_search_tax_categories_path, params: { q: "tax" } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(['No Tax']) + end + + context "with more than 30 tax categories" do + before do + create_list(:tax_category, 35) do |tax_cat, i| + tax_cat.update!(name: "Tax Category #{(i + 1).to_s.rjust(2, '0')}") + end + end + + it "returns first page with 30 results and more flag as true" do + get admin_ajax_search_tax_categories_path, params: { page: 1 } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + end + + it "returns remaining results on second page with more flag as false" do + get admin_ajax_search_tax_categories_path, params: { page: 2 } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(8) + expect(json_response["pagination"]["more"]).to be false + end + end + end + end +end From b7f154d2898174b2a0243a5f770d80da32325bf7 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Tue, 31 Mar 2026 03:49:35 +0500 Subject: [PATCH 5/6] revert back the bin/setup --- bin/setup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/setup b/bin/setup index f1e5fa0be5..a55bfa66b9 100755 --- a/bin/setup +++ b/bin/setup @@ -27,7 +27,7 @@ FileUtils.chdir APP_ROOT do system("bundle check 2> /dev/null") || system!(BUNDLE_ENV, "bundle install") # Install JavaScript dependencies - system("script/nodenv-install.sh") + system!("script/nodenv-install.sh") system!("bin/yarn") # puts "\n== Copying sample files ==" From 0a6532259485975d4f5f6d95570ec7b436a35298 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Tue, 31 Mar 2026 04:05:06 +0500 Subject: [PATCH 6/6] rename ajax_search_spec --- .../{ajax_search_controller_spec.rb => ajax_search_spec.rb} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename spec/requests/admin/{ajax_search_controller_spec.rb => ajax_search_spec.rb} (99%) diff --git a/spec/requests/admin/ajax_search_controller_spec.rb b/spec/requests/admin/ajax_search_spec.rb similarity index 99% rename from spec/requests/admin/ajax_search_controller_spec.rb rename to spec/requests/admin/ajax_search_spec.rb index b23f59efb4..154ec84728 100644 --- a/spec/requests/admin/ajax_search_controller_spec.rb +++ b/spec/requests/admin/ajax_search_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe "Admin::AjaxSearch" do +RSpec.describe "/admin/ajax_search" do include AuthenticationHelper let(:admin_user) { create(:admin_user) }