From a3c08ceb7c45dcb10877493ff45392cd737b9742 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Tue, 30 Dec 2025 01:25:25 +0500 Subject: [PATCH 01/11] Add AJAX search functionality for enterprise fees and related entities in reports --- app/controllers/admin/reports_controller.rb | 1 + .../concerns/reports/ajax_search.rb | 105 ++++++++++++++++++ app/helpers/reports_helper.rb | 21 ---- app/models/spree/ability.rb | 4 +- ...se_fees_with_tax_report_by_order.html.haml | 12 +- ...fees_with_tax_report_by_producer.html.haml | 17 +-- .../controllers/select2_ajax_controller.js | 105 ++++++++++++++++++ config/routes/admin.rb | 6 + lib/reporting/frontend_data.rb | 6 +- 9 files changed, 239 insertions(+), 38 deletions(-) create mode 100644 app/controllers/concerns/reports/ajax_search.rb create mode 100644 app/webpacker/controllers/select2_ajax_controller.js diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index bea2a2adc0..d8efede625 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -4,6 +4,7 @@ module Admin class ReportsController < Spree::Admin::BaseController include ActiveStorage::SetCurrent include ReportsActions + include Reports::AjaxSearch helper ReportsHelper diff --git a/app/controllers/concerns/reports/ajax_search.rb b/app/controllers/concerns/reports/ajax_search.rb new file mode 100644 index 0000000000..dc397c84e4 --- /dev/null +++ b/app/controllers/concerns/reports/ajax_search.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Reports + module AjaxSearch + extend ActiveSupport::Concern + + def search_enterprise_fees + report = report_class.new(spree_current_user, params, render: false) + fee_ids = enterprise_fee_ids(report.search.result) + query = EnterpriseFee.where(id: fee_ids) + + render json: build_search_response(query) + end + + def search_enterprise_fee_owners + report = report_class.new(spree_current_user, params, render: false) + owner_ids = enterprise_fee_owner_ids(report.search.result) + query = Enterprise.where(id: owner_ids) + + render json: build_search_response(query) + end + + def search_distributors + query = frontend_data.distributors + + render json: build_search_response(query) + end + + def search_order_cycles + query = frontend_data.order_cycles + + render json: build_search_response(query) + end + + def search_order_customers + query = frontend_data.order_customers + + render json: build_search_response(query) + end + + def search_suppliers + query = frontend_data.orders_suppliers + + 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? + + # Handle different model types + if query.model == OrderCycle + query.where("order_cycles.name ILIKE ?", "%#{search_term}%") + elsif query.model == Customer + query.where("customers.email ILIKE ?", "%#{search_term}%") + else + query.where("name ILIKE ?", "%#{search_term}%") + end + end + + def paginated_items(query, page, per_page) + if query.model == Customer + query.order(:email).offset((page - 1) * per_page).limit(per_page).pluck(:email, :id) + elsif query.model == OrderCycle + query.order('order_cycles.orders_close_at DESC') + .offset((page - 1) * per_page) + .limit(per_page).pluck( + :name, :id + ) + else + query.order(:name).offset((page - 1) * per_page).limit(per_page).pluck(:name, :id) + end + end + + def format_results(items) + items.map { |name, id| { id: id, text: name } } + end + + def frontend_data + @frontend_data ||= Reporting::FrontendData.new(spree_current_user) + end + + def enterprise_fee_owner_ids(orders) + EnterpriseFee.where(id: enterprise_fee_ids(orders)).select(:enterprise_id) + end + + def enterprise_fee_ids(orders) + Spree::Adjustment.enterprise_fee.where(order_id: orders.select(:id)).select(:originator_id) + end + end +end diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb index 1caab98aa9..0fa95863e0 100644 --- a/app/helpers/reports_helper.rb +++ b/app/helpers/reports_helper.rb @@ -34,29 +34,8 @@ module ReportsHelper end end - def fee_name_options(orders) - EnterpriseFee.where(id: enterprise_fee_ids(orders)) - .pluck(:name, :id) - end - - def fee_owner_options(orders) - Enterprise.where(id: enterprise_fee_owner_ids(orders)) - .pluck(:name, :id) - end - delegate :currency_symbol, to: :'Spree::Money' - def enterprise_fee_owner_ids(orders) - EnterpriseFee.where(id: enterprise_fee_ids(orders)) - .pluck(:enterprise_id) - end - - def enterprise_fee_ids(orders) - Spree::Adjustment.enterprise_fee - .where(order_id: orders.map(&:id)) - .pluck(:originator_id) - end - def datepicker_time(datetime) datetime = Time.zone.parse(datetime) if datetime.is_a? String datetime.strftime('%Y-%m-%d %H:%M') diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index 63833e309f..654b76a781 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -260,7 +260,9 @@ module Spree can [:admin, :index, :import], ::Admin::DfcProductImportsController # Reports page - can [:admin, :index, :show, :create], ::Admin::ReportsController + can [:admin, :index, :show, :create, :search_enterprise_fees, :search_enterprise_fee_owners, + :search_distributors, :search_suppliers, :search_order_cycles, :search_order_customers], + ::Admin::ReportsController can [:admin, :show, :create, :customers, :orders_and_distributors, :group_buys, :payments, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :packing, :enterprise_fee_summary, :bulk_coop, :suppliers], :report diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml index 4a3af1ef5d..6939f28eb7 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml @@ -1,23 +1,25 @@ +- search_url_query = {report_type: :enterprise_fee_summary, report_subtype: :enterprise_fees_with_tax_report_by_order} .row .alpha.two.columns= label_tag nil, t(:report_hubs) - .omega.fourteen.columns= f.collection_select(:distributor_id_in, @data.distributors, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) + .omega.fourteen.columns + = f.select(:distributor_id_in, [], {selected: params.dig(:q, :distributor_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_distributors_reports_url}}) .row .alpha.two.columns= label_tag nil, t(:report_customers_cycle) .omega.fourteen.columns - = f.select(:order_cycle_id_in, report_order_cycle_options(@data.order_cycles), {selected: params.dig(:q, :order_cycle_id_in)}, {class: "select2 fullwidth", multiple: true}) + = f.select(:order_cycle_id_in, [], {selected: params.dig(:q, :order_cycle_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_cycles_reports_url}}) .row .alpha.two.columns= label_tag nil, t(:fee_name) .omega.fourteen.columns - = f.select(:enterprise_fee_id_in, fee_name_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "select2 fullwidth", multiple: true}) + = f.select(:enterprise_fee_id_in, [], {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fees_reports_url(search_url_query)}}) .row .alpha.two.columns= label_tag nil, t(:fee_owner) .omega.fourteen.columns - = f.select(:enterprise_fee_owner_id_in, fee_owner_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "select2 fullwidth", multiple: true}) + = f.select(:enterprise_fee_owner_id_in, [], {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fee_owners_reports_url(search_url_query)}}) .row .alpha.two.columns= label_tag nil, t(:report_customers) .omega.fourteen.columns - = f.select(:customer_id_in, customer_email_options(@data.order_customers), {selected: params.dig(:q, :customer_id_in)}, {class: "select2 fullwidth", multiple: true}) + = f.select(:customer_id_in, [], {selected: params.dig(:q, :customer_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_customers_reports_url}}) diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml index 1c19788df6..ef0b54245c 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml @@ -1,27 +1,28 @@ +- search_url_query = {report_type: :enterprise_fee_summary, report_subtype: :enterprise_fees_with_tax_report_by_producer} .row .alpha.two.columns= label_tag nil, t(:report_hubs) - .omega.fourteen.columns= f.collection_select(:distributor_id_in, @data.distributors, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) - + .omega.fourteen.columns + = f.select(:distributor_id_in, [], {selected: params.dig(:q, :distributor_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_distributors_reports_url}}) .row .alpha.two.columns= label_tag nil, t(:report_producers) - .omega.fourteen.columns= select_tag(:supplier_id_in, options_from_collection_for_select(@data.orders_suppliers, :id, :name, params[:supplier_id_in]), {class: "select2 fullwidth", multiple: true}) + .omega.fourteen.columns + = select_tag(:supplier_id_in, [], {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_suppliers_reports_url}}) .row .alpha.two.columns= label_tag nil, t(:report_customers_cycle) .omega.fourteen.columns - = f.select(:order_cycle_id_in, report_order_cycle_options(@data.order_cycles), {selected: params.dig(:q, :order_cycle_id_in)}, {class: "select2 fullwidth", multiple: true}) - + = f.select(:order_cycle_id_in, [], {selected: params.dig(:q, :order_cycle_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_cycles_reports_url}}) .row .alpha.two.columns= label_tag nil, t(:fee_name) .omega.fourteen.columns - = f.select(:enterprise_fee_id_in, fee_name_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "select2 fullwidth", multiple: true}) + = f.select(:enterprise_fee_id_in, [], {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fees_reports_url(search_url_query)}}) .row .alpha.two.columns= label_tag nil, t(:fee_owner) .omega.fourteen.columns - = f.select(:enterprise_fee_owner_id_in, fee_owner_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "select2 fullwidth", multiple: true}) + = f.select(:enterprise_fee_owner_id_in, [], {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fee_owners_reports_url(search_url_query)}}) .row .alpha.two.columns= label_tag nil, t(:report_customers) .omega.fourteen.columns - = f.select(:customer_id_in, customer_email_options(@data.order_customers), {selected: params.dig(:q, :customer_id_in)}, {class: "select2 fullwidth", multiple: true}) + = f.select(:customer_id_in, [], {selected: params.dig(:q, :customer_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_customers_reports_url}}) diff --git a/app/webpacker/controllers/select2_ajax_controller.js b/app/webpacker/controllers/select2_ajax_controller.js new file mode 100644 index 0000000000..24735406e0 --- /dev/null +++ b/app/webpacker/controllers/select2_ajax_controller.js @@ -0,0 +1,105 @@ +import { Controller } from "stimulus"; + +export default class extends Controller { + static values = { + url: String, + }; + + connect() { + if (typeof $ === "undefined" || typeof $.fn.select2 === "undefined") { + console.error("Select2 AJAX Controller: jQuery or Select2 not loaded"); + return; + } + + const ajaxUrl = this.urlValue; + if (!ajaxUrl) return; + + const selectName = this.element.name; + const selectId = this.element.id; + const isMultiple = this.element.multiple; + + const container = document.createElement("div"); + container.dataset.select2HiddenContainer = "true"; + + this.element.replaceWith(container); + + // select2 methods are accessible via jQuery + // Plus, ajax calls with multi-select require a hidden input in select2 + const $select2Input = $(''); + $select2Input.attr("id", selectId); + container.appendChild($select2Input[0]); + + // IN-MEMORY cache to avoid repeated ajax calls for same query/page + const ajaxCache = {}; + + const select2Options = { + ajax: { + url: ajaxUrl, + dataType: "json", + quietMillis: 300, + data: function (term, page) { + return { + q: term || "", + page: page || 1, + }; + }, + transport: function (params) { + const term = params.data.q || ""; + const page = params.data.page || 1; + const cacheKey = `${term}::${page}`; + + if (ajaxCache[cacheKey]) { + params.success(ajaxCache[cacheKey]); + return; + } + + const request = $.ajax(params); + + request.then((data) => { + ajaxCache[cacheKey] = data; + params.success(data); + }); + + return request; + }, + results: function (data, _page) { + return { + results: data.results || [], + more: (data.pagination && data.pagination.more) || false, + }; + }, + }, + allowClear: true, + minimumInputLength: 0, + multiple: isMultiple, + width: "100%", + formatResult: (item) => item.text, + formatSelection: (item) => item.text, + }; + + // Initialize select2 with ajax options on hidden input + $select2Input.select2(select2Options); + + // Rails-style array submission requires multiple hidden inputs with same name + const syncHiddenInputs = (values) => { + // remove old inputs + container.querySelectorAll(`input[name="${selectName}"]`).forEach((e) => e.remove()); + + values.forEach((value) => { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = selectName; + input.value = value; + container.appendChild(input); + }); + }; + + // On change → rebuild hidden inputs to submit filter values + $select2Input.on("change", () => { + const valuesString = $select2Input.val() || ""; + const values = valuesString.split(",") || []; + + syncHiddenInputs(Array.isArray(values) ? values : [values]); + }); + } +} diff --git a/config/routes/admin.rb b/config/routes/admin.rb index b80d340bd4..68c60b29f9 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -137,6 +137,12 @@ Openfoodnetwork::Application.routes.draw do end get '/reports', to: 'reports#index', as: :reports + get '/reports/search_enterprise_fees', to: 'reports#search_enterprise_fees', as: :search_enterprise_fees_reports + get '/reports/search_enterprise_fee_owners', to: 'reports#search_enterprise_fee_owners', as: :search_enterprise_fee_owners_reports + get '/reports/search_distributors', to: 'reports#search_distributors', as: :search_distributors_reports + get '/reports/search_suppliers', to: 'reports#search_suppliers', as: :search_suppliers_reports + get '/reports/search_order_cycles', to: 'reports#search_order_cycles', as: :search_order_cycles_reports + get '/reports/search_order_customers', to: 'reports#search_order_customers', as: :search_order_customers_reports match '/reports/:report_type(/:report_subtype)', to: 'reports#show', via: :get, as: :report match '/reports/:report_type(/:report_subtype)', to: 'reports#create', via: :post end diff --git a/lib/reporting/frontend_data.rb b/lib/reporting/frontend_data.rb index 5a1152d4a2..682e071fb1 100644 --- a/lib/reporting/frontend_data.rb +++ b/lib/reporting/frontend_data.rb @@ -46,7 +46,7 @@ module Reporting end def order_customers - Customer.where(id: visible_order_customer_ids).select("customers.id, customers.email") + Customer.where(id: visible_order_customer_ids_query).select("customers.id, customers.email") end private @@ -57,8 +57,8 @@ module Reporting @permissions ||= OpenFoodNetwork::Permissions.new(current_user) end - def visible_order_customer_ids - Permissions::Order.new(current_user).visible_orders.pluck(:customer_id) + def visible_order_customer_ids_query + Permissions::Order.new(current_user).visible_orders.select(:customer_id) end end end From 03b7c07495b0e8705182714cf13874143820ca80 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Tue, 30 Dec 2025 01:25:35 +0500 Subject: [PATCH 02/11] Add tests for Select2AjaxController with AJAX functionality --- .../admin/reports_controller_spec.rb | 149 +++++++++++++++++ .../stimulus/select2_ajax_controller_test.js | 156 ++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 spec/javascripts/stimulus/select2_ajax_controller_test.js diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb index d0b6104d8c..2f6404cba6 100644 --- a/spec/controllers/admin/reports_controller_spec.rb +++ b/spec/controllers/admin/reports_controller_spec.rb @@ -370,4 +370,153 @@ RSpec.describe Admin::ReportsController do end end end + + context "AJAX Search" do + let(:enterprise_fee1) { + create(:enterprise_fee, name: "Delivery Fee", enterprise: distributor1) + } + let(:enterprise_fee2) { create(:enterprise_fee, name: "Admin Fee", enterprise: distributor2) } + + before do + controller_login_as_admin + orderA1.adjustments.create!( + originator: enterprise_fee1, + label: "Delivery Fee", + amount: 5.0, + state: "finalized", + order: orderA1 + ) + orderB1.adjustments.create!( + originator: enterprise_fee2, + label: "Admin Fee", + amount: 3.0, + state: "finalized", + order: orderB1 + ) + end + + describe "#search_enterprise_fees" do + it "returns paginated JSON with enterprise fees ordered by name" do + spree_get( + :search_enterprise_fees, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + ) + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + names = json_response["results"].pluck("text") + expect(names).to eq(['Admin Fee', 'Delivery Fee']) + end + + it "caches and works with different report types" do + spree_get( + :search_enterprise_fees, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + ) + first_response = response.parsed_body + + spree_get( + :search_enterprise_fees, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + ) + second_response = response.parsed_body + + expect(first_response).to eq(second_response) + end + end + + describe "#search_enterprise_fee_owners" do + it "returns paginated JSON with unique enterprise owners ordered by name" do + distributor1.update!(name: "Zebra Farm") + distributor2.update!(name: "Alpha Market") + + spree_get( + :search_enterprise_fee_owners, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + ) + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + names = json_response["results"].pluck("text") + expect(names).to eq(['Alpha Market', 'Zebra Farm']) + end + + it "caches results" do + spree_get( + :search_enterprise_fee_owners, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + ) + first_response = response.parsed_body + + spree_get( + :search_enterprise_fee_owners, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + ) + second_response = response.parsed_body + + expect(first_response).to eq(second_response) + end + end + + describe "#search_order_customers" do + it "filters customers by email and returns paginated results" do + customer1 = create(:customer, email: "alice@example.com", enterprise: distributor1) + customer2 = create(:customer, email: "bob@example.com", enterprise: distributor1) + orderA1.update!(customer: customer1) + orderA2.update!(customer: customer2) + + spree_get( + :search_order_customers, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + q: "alice" + ) + + json_response = response.parsed_body + expect(json_response["results"].pluck("text")).to eq(["alice@example.com"]) + end + end + + describe "#search_order_cycles" do + it "filters order cycles by name and orders by close date" do + ocA.update!(name: "Winter Market") + ocB.update!(name: "Summer Market") + + spree_get( + :search_order_cycles, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + q: "Winter" + ) + + json_response = response.parsed_body + expect(json_response["results"].pluck("text")).to eq(["Winter Market"]) + end + end + + describe "#search_distributors" do + it "filters distributors by name" do + distributor1.update!(name: "Alpha Farm") + distributor2.update!(name: "Beta Market") + + spree_get( + :search_distributors, + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + q: "Alpha" + ) + + json_response = response.parsed_body + expect(json_response["results"].pluck("text")).to eq(["Alpha Farm"]) + end + end + end end diff --git a/spec/javascripts/stimulus/select2_ajax_controller_test.js b/spec/javascripts/stimulus/select2_ajax_controller_test.js new file mode 100644 index 0000000000..3b1d449a7a --- /dev/null +++ b/spec/javascripts/stimulus/select2_ajax_controller_test.js @@ -0,0 +1,156 @@ +/** + * @jest-environment jsdom + */ + +import { Application } from "stimulus"; +import select2_ajax_controller from "../../../app/webpacker/controllers/select2_ajax_controller.js"; + +describe("Select2AjaxController", () => { + let select2InitOptions = null; + let application; + + beforeAll(() => { + application = Application.start(); + application.register("select2-ajax", select2_ajax_controller); + }); + + beforeEach(() => { + select2InitOptions = null; + + // Mock jQuery and select2 + const mockVal = jest.fn(function (value) { + if (value !== undefined) { + this._value = value; + return this; + } + return this._value || ""; + }); + + const mockOn = jest.fn().mockReturnThis(); + + const mockSelect2 = jest.fn(function (options) { + if (typeof options === "string" && options === "destroy") { + return this; + } + select2InitOptions = options; + return this; + }); + + const jQueryMock = jest.fn((selector) => { + let element; + if (typeof selector === "string" && selector.startsWith(" + + + `; + }); + + afterEach(() => { + document.body.innerHTML = ""; + delete global.$; + }); + + describe("#connect", () => { + it("initializes select2 with correct AJAX URL", () => { + expect(select2InitOptions).not.toBeNull(); + expect(select2InitOptions.ajax.url).toBe("/api/search"); + }); + + it("configures select2 with correct options", () => { + expect(select2InitOptions.ajax.dataType).toBe("json"); + expect(select2InitOptions.ajax.quietMillis).toBe(300); + expect(select2InitOptions.allowClear).toBe(true); + expect(select2InitOptions.minimumInputLength).toBe(0); + expect(select2InitOptions.width).toBe("100%"); + }); + + it("configures AJAX data function", () => { + const dataFunc = select2InitOptions.ajax.data; + const result = dataFunc("search term", 2); + + expect(result).toEqual({ + q: "search term", + page: 2, + }); + }); + + it("handles empty search term", () => { + const dataFunc = select2InitOptions.ajax.data; + const result = dataFunc(null, null); + + expect(result).toEqual({ + q: "", + page: 1, + }); + }); + + it("configures results function with pagination", () => { + const resultsFunc = select2InitOptions.ajax.results; + const mockData = { + results: [{ id: 1, text: "Item 1" }], + pagination: { more: true }, + }; + + const result = resultsFunc(mockData, 1); + + expect(result).toEqual({ + results: mockData.results, + more: true, + }); + }); + + it("handles missing pagination", () => { + const resultsFunc = select2InitOptions.ajax.results; + const mockData = { + results: [{ id: 1, text: "Item 1" }], + }; + + const result = resultsFunc(mockData, 1); + + expect(result).toEqual({ + results: mockData.results, + more: false, + }); + }); + + it("configures format functions", () => { + const item = { id: 1, text: "Test Item" }; + + expect(select2InitOptions.formatResult(item)).toBe("Test Item"); + expect(select2InitOptions.formatSelection(item)).toBe("Test Item"); + }); + }); +}); From 77fe1fa6f9306d38466bb97a3db47df612fa424e Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Sun, 25 Jan 2026 11:14:49 +0500 Subject: [PATCH 03/11] Refactor SearchableDropdownComponent and integrate remote data loading with TomSelect --- .../searchable_dropdown_component.rb | 37 +- .../searchable_dropdown_component.html.haml | 5 +- .../concerns/reports/ajax_search.rb | 2 +- ...se_fees_with_tax_report_by_order.html.haml | 35 +- ...fees_with_tax_report_by_producer.html.haml | 41 ++- .../controllers/select2_ajax_controller.js | 105 ------ .../controllers/tom_select_controller.js | 156 ++++++++- jest.config.js | 2 +- .../admin/reports_controller_spec.rb | 46 +-- .../stimulus/select2_ajax_controller_test.js | 156 --------- .../stimulus/tom_select_controller_test.js | 318 ++++++++++++++++++ 11 files changed, 577 insertions(+), 326 deletions(-) delete mode 100644 app/webpacker/controllers/select2_ajax_controller.js delete mode 100644 spec/javascripts/stimulus/select2_ajax_controller_test.js create mode 100644 spec/javascripts/stimulus/tom_select_controller_test.js diff --git a/app/components/searchable_dropdown_component.rb b/app/components/searchable_dropdown_component.rb index 1f4d5f9e87..821bf89979 100644 --- a/app/components/searchable_dropdown_component.rb +++ b/app/components/searchable_dropdown_component.rb @@ -1,17 +1,18 @@ # frozen_string_literal: true class SearchableDropdownComponent < ViewComponent::Base - REMOVED_SEARCH_PLUGIN = { 'tom-select-options-value': '{ "plugins": [] }' }.freeze MINIMUM_OPTIONS_FOR_SEARCH_FIELD = 11 # at least 11 options are required for the search field def initialize( - form:, name:, options:, selected_option:, - placeholder_value:, + form: nil, + placeholder_value: '', include_blank: false, aria_label: '', + multiple: false, + remote_url: nil, other_attrs: {} ) @f = form @@ -21,13 +22,15 @@ class SearchableDropdownComponent < ViewComponent::Base @placeholder_value = placeholder_value @include_blank = include_blank @aria_label = aria_label + @multiple = multiple + @remote_url = remote_url @other_attrs = other_attrs end private attr_reader :f, :name, :options, :selected_option, :placeholder_value, :include_blank, - :aria_label, :other_attrs + :aria_label, :multiple, :remote_url, :other_attrs def classes "fullwidth #{'no-input' if remove_search_plugin?}" @@ -36,11 +39,31 @@ class SearchableDropdownComponent < ViewComponent::Base def data { controller: "tom-select", - 'tom-select-placeholder-value': placeholder_value - }.merge(remove_search_plugin? ? REMOVED_SEARCH_PLUGIN : {}) + 'tom-select-placeholder-value': placeholder_value, + 'tom-select-options-value': tom_select_options_value, + 'tom-select-remote-url-value': remote_url, + } + end + + def tom_select_options_value + plugins = remove_search_plugin? ? [] : ['dropdown_input'] + multiple ? plugins << 'remove_button' : plugins + + { + plugins:, + maxItems: multiple ? nil : 1, + } + end + + def uses_form_builder? + f.present? end def remove_search_plugin? - @remove_search_plugin ||= options.count < MINIMUM_OPTIONS_FOR_SEARCH_FIELD + # Remove the search plugin when: + # - the select is multiple (it already includes a search field), or + # - there is no remote URL and the options are below the search threshold + @remove_search_plugin ||= multiple || + (@remote_url.nil? && options.count < MINIMUM_OPTIONS_FOR_SEARCH_FIELD) end end diff --git a/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml index 14d8969348..85529d4c89 100644 --- a/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml +++ b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml @@ -1 +1,4 @@ -= f.select name, options_for_select(options, selected_option), { include_blank: }, class: classes, data:, 'aria-label': aria_label, **other_attrs +- if uses_form_builder? + = f.select name, options, { selected: selected_option, include_blank:, multiple: }, class: classes, data:, 'aria-label': aria_label, **other_attrs +- else + = select_tag name, options_for_select(options, selected_option), include_blank: include_blank, multiple: multiple, class: classes, data: data, 'aria-label': aria_label, **other_attrs diff --git a/app/controllers/concerns/reports/ajax_search.rb b/app/controllers/concerns/reports/ajax_search.rb index dc397c84e4..7194dc77a7 100644 --- a/app/controllers/concerns/reports/ajax_search.rb +++ b/app/controllers/concerns/reports/ajax_search.rb @@ -87,7 +87,7 @@ module Reports end def format_results(items) - items.map { |name, id| { id: id, text: name } } + items.map { |label, value| { value:, label: } } end def frontend_data diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml index 6939f28eb7..1001886995 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml @@ -2,24 +2,49 @@ .row .alpha.two.columns= label_tag nil, t(:report_hubs) .omega.fourteen.columns - = f.select(:distributor_id_in, [], {selected: params.dig(:q, :distributor_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_distributors_reports_url}}) + = render(SearchableDropdownComponent.new(form: f, + name: :distributor_id_in, + options: [], + selected_option: params.dig(:q, :distributor_id_in), + multiple: true, + remote_url: admin_search_distributors_reports_url)) .row .alpha.two.columns= label_tag nil, t(:report_customers_cycle) .omega.fourteen.columns - = f.select(:order_cycle_id_in, [], {selected: params.dig(:q, :order_cycle_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_cycles_reports_url}}) + = render(SearchableDropdownComponent.new(form: f, + name: :order_cycle_id_in, + options: [], + selected_option: params.dig(:q, :order_cycle_id_in), + multiple: true, + remote_url: admin_search_order_cycles_reports_url)) .row .alpha.two.columns= label_tag nil, t(:fee_name) .omega.fourteen.columns - = f.select(:enterprise_fee_id_in, [], {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fees_reports_url(search_url_query)}}) + = render(SearchableDropdownComponent.new(form: f, + name: :enterprise_fee_id_in, + options: [], + selected_option: params.dig(:q, :enterprise_fee_id_in), + multiple: true, + remote_url: admin_search_enterprise_fees_reports_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:fee_owner) .omega.fourteen.columns - = f.select(:enterprise_fee_owner_id_in, [], {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fee_owners_reports_url(search_url_query)}}) + = render(SearchableDropdownComponent.new(form: f, + name: :enterprise_fee_owner_id_in, + options: [], + selected_option: params.dig(:q, :enterprise_fee_owner_id_in), + multiple: true, + remote_url: admin_search_enterprise_fee_owners_reports_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:report_customers) .omega.fourteen.columns - = f.select(:customer_id_in, [], {selected: params.dig(:q, :customer_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_customers_reports_url}}) + = render(SearchableDropdownComponent.new(form: f, + name: :customer_id_in, + options: [], + selected_option: params.dig(:q, :customer_id_in), + multiple: true, + remote_url: admin_search_order_customers_reports_url)) diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml index ef0b54245c..c71e92ae58 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml @@ -2,27 +2,56 @@ .row .alpha.two.columns= label_tag nil, t(:report_hubs) .omega.fourteen.columns - = f.select(:distributor_id_in, [], {selected: params.dig(:q, :distributor_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_distributors_reports_url}}) + = render(SearchableDropdownComponent.new(form: f, + name: :distributor_id_in, + options: [], + selected_option: params.dig(:q, :distributor_id_in), + multiple: true, + remote_url: admin_search_distributors_reports_url)) .row .alpha.two.columns= label_tag nil, t(:report_producers) .omega.fourteen.columns - = select_tag(:supplier_id_in, [], {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_suppliers_reports_url}}) + = render(SearchableDropdownComponent.new(name: :supplier_id_in, + options: [], + selected_option: params.dig(:supplier_id_in), + multiple: true, + remote_url: admin_search_suppliers_reports_url)) .row .alpha.two.columns= label_tag nil, t(:report_customers_cycle) .omega.fourteen.columns - = f.select(:order_cycle_id_in, [], {selected: params.dig(:q, :order_cycle_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_cycles_reports_url}}) + = render(SearchableDropdownComponent.new(form: f, + name: :order_cycle_id_in, + options: [], + selected_option: params.dig(:q, :order_cycle_id_in), + multiple: true, + remote_url: admin_search_order_cycles_reports_url)) .row .alpha.two.columns= label_tag nil, t(:fee_name) .omega.fourteen.columns - = f.select(:enterprise_fee_id_in, [], {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fees_reports_url(search_url_query)}}) + = render(SearchableDropdownComponent.new(form: f, + name: :enterprise_fee_id_in, + options: [], + selected_option: params.dig(:q, :enterprise_fee_id_in), + multiple: true, + remote_url: admin_search_enterprise_fees_reports_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:fee_owner) .omega.fourteen.columns - = f.select(:enterprise_fee_owner_id_in, [], {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_enterprise_fee_owners_reports_url(search_url_query)}}) + = render(SearchableDropdownComponent.new(form: f, + name: :enterprise_fee_owner_id_in, + options: [], + selected_option: params.dig(:q, :enterprise_fee_owner_id_in), + multiple: true, + remote_url: admin_search_enterprise_fee_owners_reports_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:report_customers) .omega.fourteen.columns - = f.select(:customer_id_in, [], {selected: params.dig(:q, :customer_id_in)}, {class: "fullwidth", multiple: true, data: {controller: "select2-ajax", select2_ajax_url_value: admin_search_order_customers_reports_url}}) + = render(SearchableDropdownComponent.new(form: f, + name: :customer_id_in, + options: [], + selected_option: params.dig(:q, :customer_id_in), + multiple: true, + remote_url: admin_search_order_customers_reports_url)) diff --git a/app/webpacker/controllers/select2_ajax_controller.js b/app/webpacker/controllers/select2_ajax_controller.js deleted file mode 100644 index 24735406e0..0000000000 --- a/app/webpacker/controllers/select2_ajax_controller.js +++ /dev/null @@ -1,105 +0,0 @@ -import { Controller } from "stimulus"; - -export default class extends Controller { - static values = { - url: String, - }; - - connect() { - if (typeof $ === "undefined" || typeof $.fn.select2 === "undefined") { - console.error("Select2 AJAX Controller: jQuery or Select2 not loaded"); - return; - } - - const ajaxUrl = this.urlValue; - if (!ajaxUrl) return; - - const selectName = this.element.name; - const selectId = this.element.id; - const isMultiple = this.element.multiple; - - const container = document.createElement("div"); - container.dataset.select2HiddenContainer = "true"; - - this.element.replaceWith(container); - - // select2 methods are accessible via jQuery - // Plus, ajax calls with multi-select require a hidden input in select2 - const $select2Input = $(''); - $select2Input.attr("id", selectId); - container.appendChild($select2Input[0]); - - // IN-MEMORY cache to avoid repeated ajax calls for same query/page - const ajaxCache = {}; - - const select2Options = { - ajax: { - url: ajaxUrl, - dataType: "json", - quietMillis: 300, - data: function (term, page) { - return { - q: term || "", - page: page || 1, - }; - }, - transport: function (params) { - const term = params.data.q || ""; - const page = params.data.page || 1; - const cacheKey = `${term}::${page}`; - - if (ajaxCache[cacheKey]) { - params.success(ajaxCache[cacheKey]); - return; - } - - const request = $.ajax(params); - - request.then((data) => { - ajaxCache[cacheKey] = data; - params.success(data); - }); - - return request; - }, - results: function (data, _page) { - return { - results: data.results || [], - more: (data.pagination && data.pagination.more) || false, - }; - }, - }, - allowClear: true, - minimumInputLength: 0, - multiple: isMultiple, - width: "100%", - formatResult: (item) => item.text, - formatSelection: (item) => item.text, - }; - - // Initialize select2 with ajax options on hidden input - $select2Input.select2(select2Options); - - // Rails-style array submission requires multiple hidden inputs with same name - const syncHiddenInputs = (values) => { - // remove old inputs - container.querySelectorAll(`input[name="${selectName}"]`).forEach((e) => e.remove()); - - values.forEach((value) => { - const input = document.createElement("input"); - input.type = "hidden"; - input.name = selectName; - input.value = value; - container.appendChild(input); - }); - }; - - // On change → rebuild hidden inputs to submit filter values - $select2Input.on("change", () => { - const valuesString = $select2Input.val() || ""; - const values = valuesString.split(",") || []; - - syncHiddenInputs(Array.isArray(values) ? values : [values]); - }); - } -} diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js index d1d3ade58a..81651f038b 100644 --- a/app/webpacker/controllers/tom_select_controller.js +++ b/app/webpacker/controllers/tom_select_controller.js @@ -2,10 +2,14 @@ import { Controller } from "stimulus"; import TomSelect from "tom-select/dist/esm/tom-select.complete"; export default class extends Controller { - static values = { options: Object, placeholder: String }; + static values = { + options: Object, + placeholder: String, + remoteUrl: String, + }; connect(options = {}) { - this.control = new TomSelect(this.element, { + let tomSelectOptions = { maxItems: 1, maxOptions: null, plugins: ["dropdown_input"], @@ -16,7 +20,13 @@ export default class extends Controller { }, ...this.optionsValue, ...options, - }); + }; + + if (this.remoteUrlValue) { + this.#addRemoteOptions(tomSelectOptions); + } + + this.control = new TomSelect(this.element, tomSelectOptions); } disconnect() { @@ -29,4 +39,144 @@ export default class extends Controller { const optionsArray = [...this.element.options]; return optionsArray.find((option) => [null, ""].includes(option.value))?.text; } + + #addRemoteOptions(options) { + // --- Pagination & request state --- + this.page = 1; + this.hasMore = true; + this.loading = false; + this.lastQuery = ""; + this.scrollAttached = false; + + const buildUrl = (query) => { + const url = new URL(this.remoteUrlValue, window.location.origin); + url.searchParams.set("q", query); + url.searchParams.set("page", this.page); + return url; + }; + + /** + * Shared remote fetch handler. + * Owns: + * - request lifecycle + * - pagination state + * - loading UI when appending + */ + const fetchOptions = ({ query, append = false, callback }) => { + if (this.loading || !this.hasMore) { + callback?.(); + return; + } + + this.loading = true; + + const dropdown = this.control?.dropdown_content; + const previousScrollTop = dropdown?.scrollTop; + let loader; + + /** + * When appending (infinite scroll), TomSelect does NOT + * manage loading UI automatically — we must do it manually. + */ + if (append && dropdown) { + loader = this.control.render("loading"); + dropdown.appendChild(loader); + this.control.wrapper.classList.add(this.control.settings.loadingClass); + } + + fetch(buildUrl(query)) + .then((response) => response.json()) + .then((json) => { + /** + * Expected API shape: + * { + * results: [{ value, label }], + * pagination: { more: boolean } + * } + */ + this.hasMore = Boolean(json.pagination?.more); + this.page += 1; + + const results = json.results || []; + + if (append && dropdown) { + this.control.addOptions(results); + this.control.refreshOptions(false); + /** + * Preserve scroll position so newly appended + * options don’t cause visual jumping. + */ + requestAnimationFrame(() => { + dropdown.scrollTop = previousScrollTop; + }); + } else { + callback?.(results); + } + }) + .catch(() => { + callback?.(); + }) + .finally(() => { + this.loading = false; + + if (append && loader) { + this.control.wrapper.classList.remove(this.control.settings.loadingClass); + loader.remove(); + } + }); + }; + + options.load = function (query, callback) { + fetchOptions({ query, callback }); + }.bind(this); + + options.onType = function (query) { + if (query === this.lastQuery) return; + + this.lastQuery = query; + this.page = 1; + this.hasMore = true; + }.bind(this); + + options.onDropdownOpen = function () { + if (this.scrollAttached) return; + this.scrollAttached = true; + + const dropdown = this.control.dropdown_content; + + dropdown.addEventListener( + "scroll", + function () { + const nearBottom = + dropdown.scrollTop + dropdown.clientHeight >= dropdown.scrollHeight - 20; + + if (nearBottom) { + this.#fetchNextPage(); + } + }.bind(this), + ); + }.bind(this); + + options.onFocus = function () { + if (this.loading) return; + + this.lastQuery = ""; + this.control.load("", () => {}); + }.bind(this); + + options.valueField = "value"; + options.labelField = "label"; + options.searchField = "label"; + + this._fetchOptions = fetchOptions; + } + + #fetchNextPage() { + if (this.loading || !this.hasMore) return; + + this._fetchOptions({ + query: this.lastQuery, + append: true, + }); + } } diff --git a/jest.config.js b/jest.config.js index df6f89cec9..2e451b9051 100644 --- a/jest.config.js +++ b/jest.config.js @@ -179,7 +179,7 @@ module.exports = { // transform: { "\\.[jt]sx?$": "babel-jest" }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - transformIgnorePatterns: ["/node_modules/(?!(stimulus.+)/)"], + transformIgnorePatterns: ["/node_modules/(?!(stimulus.+|tom-select)/)"], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb index 2f6404cba6..14d7a53463 100644 --- a/spec/controllers/admin/reports_controller_spec.rb +++ b/spec/controllers/admin/reports_controller_spec.rb @@ -406,27 +406,9 @@ RSpec.describe Admin::ReportsController do expect(response).to have_http_status(:ok) json_response = response.parsed_body - names = json_response["results"].pluck("text") + names = json_response["results"].pluck("label") expect(names).to eq(['Admin Fee', 'Delivery Fee']) end - - it "caches and works with different report types" do - spree_get( - :search_enterprise_fees, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - ) - first_response = response.parsed_body - - spree_get( - :search_enterprise_fees, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - ) - second_response = response.parsed_body - - expect(first_response).to eq(second_response) - end end describe "#search_enterprise_fee_owners" do @@ -443,27 +425,9 @@ RSpec.describe Admin::ReportsController do expect(response).to have_http_status(:ok) json_response = response.parsed_body - names = json_response["results"].pluck("text") + names = json_response["results"].pluck("label") expect(names).to eq(['Alpha Market', 'Zebra Farm']) end - - it "caches results" do - spree_get( - :search_enterprise_fee_owners, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - ) - first_response = response.parsed_body - - spree_get( - :search_enterprise_fee_owners, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - ) - second_response = response.parsed_body - - expect(first_response).to eq(second_response) - end end describe "#search_order_customers" do @@ -481,7 +445,7 @@ RSpec.describe Admin::ReportsController do ) json_response = response.parsed_body - expect(json_response["results"].pluck("text")).to eq(["alice@example.com"]) + expect(json_response["results"].pluck("label")).to eq(["alice@example.com"]) end end @@ -498,7 +462,7 @@ RSpec.describe Admin::ReportsController do ) json_response = response.parsed_body - expect(json_response["results"].pluck("text")).to eq(["Winter Market"]) + expect(json_response["results"].pluck("label")).to eq(["Winter Market"]) end end @@ -515,7 +479,7 @@ RSpec.describe Admin::ReportsController do ) json_response = response.parsed_body - expect(json_response["results"].pluck("text")).to eq(["Alpha Farm"]) + expect(json_response["results"].pluck("label")).to eq(["Alpha Farm"]) end end end diff --git a/spec/javascripts/stimulus/select2_ajax_controller_test.js b/spec/javascripts/stimulus/select2_ajax_controller_test.js deleted file mode 100644 index 3b1d449a7a..0000000000 --- a/spec/javascripts/stimulus/select2_ajax_controller_test.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { Application } from "stimulus"; -import select2_ajax_controller from "../../../app/webpacker/controllers/select2_ajax_controller.js"; - -describe("Select2AjaxController", () => { - let select2InitOptions = null; - let application; - - beforeAll(() => { - application = Application.start(); - application.register("select2-ajax", select2_ajax_controller); - }); - - beforeEach(() => { - select2InitOptions = null; - - // Mock jQuery and select2 - const mockVal = jest.fn(function (value) { - if (value !== undefined) { - this._value = value; - return this; - } - return this._value || ""; - }); - - const mockOn = jest.fn().mockReturnThis(); - - const mockSelect2 = jest.fn(function (options) { - if (typeof options === "string" && options === "destroy") { - return this; - } - select2InitOptions = options; - return this; - }); - - const jQueryMock = jest.fn((selector) => { - let element; - if (typeof selector === "string" && selector.startsWith(" - - - `; - }); - - afterEach(() => { - document.body.innerHTML = ""; - delete global.$; - }); - - describe("#connect", () => { - it("initializes select2 with correct AJAX URL", () => { - expect(select2InitOptions).not.toBeNull(); - expect(select2InitOptions.ajax.url).toBe("/api/search"); - }); - - it("configures select2 with correct options", () => { - expect(select2InitOptions.ajax.dataType).toBe("json"); - expect(select2InitOptions.ajax.quietMillis).toBe(300); - expect(select2InitOptions.allowClear).toBe(true); - expect(select2InitOptions.minimumInputLength).toBe(0); - expect(select2InitOptions.width).toBe("100%"); - }); - - it("configures AJAX data function", () => { - const dataFunc = select2InitOptions.ajax.data; - const result = dataFunc("search term", 2); - - expect(result).toEqual({ - q: "search term", - page: 2, - }); - }); - - it("handles empty search term", () => { - const dataFunc = select2InitOptions.ajax.data; - const result = dataFunc(null, null); - - expect(result).toEqual({ - q: "", - page: 1, - }); - }); - - it("configures results function with pagination", () => { - const resultsFunc = select2InitOptions.ajax.results; - const mockData = { - results: [{ id: 1, text: "Item 1" }], - pagination: { more: true }, - }; - - const result = resultsFunc(mockData, 1); - - expect(result).toEqual({ - results: mockData.results, - more: true, - }); - }); - - it("handles missing pagination", () => { - const resultsFunc = select2InitOptions.ajax.results; - const mockData = { - results: [{ id: 1, text: "Item 1" }], - }; - - const result = resultsFunc(mockData, 1); - - expect(result).toEqual({ - results: mockData.results, - more: false, - }); - }); - - it("configures format functions", () => { - const item = { id: 1, text: "Test Item" }; - - expect(select2InitOptions.formatResult(item)).toBe("Test Item"); - expect(select2InitOptions.formatSelection(item)).toBe("Test Item"); - }); - }); -}); diff --git a/spec/javascripts/stimulus/tom_select_controller_test.js b/spec/javascripts/stimulus/tom_select_controller_test.js new file mode 100644 index 0000000000..f53bae3ff7 --- /dev/null +++ b/spec/javascripts/stimulus/tom_select_controller_test.js @@ -0,0 +1,318 @@ +/** + * @jest-environment jsdom + */ + +import { Application } from "stimulus"; +import tom_select_controller from "../../../app/webpacker/controllers/tom_select_controller.js"; + +describe("TomSelectController", () => { + let application; + + beforeAll(() => { + application = Application.start(); + application.register("tom-select", tom_select_controller); + }); + + beforeEach(() => { + global.requestAnimationFrame = jest.fn((cb) => { + cb(); + return 1; + }); + + // Mock fetch for remote data tests + global.fetch = jest.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); + }); + + describe("basic initialization", () => { + it("initializes TomSelect with default options and settings", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.getElementById("test-select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + expect(controller.control).toBeDefined(); + expect(controller.control.settings.maxItems).toBe(1); + expect(controller.control.settings.maxOptions).toBeNull(); + expect(controller.control.settings.allowEmptyOption).toBe(true); + expect(controller.control.settings.placeholder).toBe("Choose an option"); + expect(controller.control.settings.plugins).toContain("dropdown_input"); + expect(controller.control.settings.onItemAdd).toBeDefined(); + }); + + it("uses empty option text as placeholder when no placeholder value provided", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + expect(controller.control.settings.placeholder).toBe("-- Default Placeholder --"); + }); + + it("accepts custom options via data attribute", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + expect(controller.control.settings.maxItems).toBe(3); + expect(controller.control.settings.create).toBe(true); + }); + + it("cleans up on disconnect", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + const destroySpy = jest.spyOn(controller.control, "destroy"); + controller.disconnect(); + + expect(destroySpy).toHaveBeenCalled(); + }); + }); + + describe("remote data loading (#addRemoteOptions)", () => { + it("configures remote loading with proper field and pagination setup", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + expect(controller.page).toBe(1); + expect(controller.hasMore).toBe(true); + expect(controller.loading).toBe(false); + expect(controller.scrollAttached).toBe(false); + expect(controller.control.settings.valueField).toBe("value"); + expect(controller.control.settings.labelField).toBe("label"); + expect(controller.control.settings.searchField).toBe("label"); + }); + + it("resets pagination on new query but preserves on same query", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + + controller.page = 5; + controller.hasMore = false; + controller.lastQuery = "old"; + + // Same query preserves state + controller.control.settings.onType("old"); + expect(controller.page).toBe(5); + + // New query resets state + controller.control.settings.onType("new"); + expect(controller.page).toBe(1); + expect(controller.hasMore).toBe(true); + expect(controller.lastQuery).toBe("new"); + }); + + it("loads initial data on focus when not loading", async () => { + global.fetch.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + results: [], + pagination: { more: false }, + }), + }); + + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + + const loadSpy = jest.spyOn(controller.control, "load"); + controller.control.settings.onFocus(); + + expect(loadSpy).toHaveBeenCalledWith("", expect.any(Function)); + expect(controller.lastQuery).toBe(""); + + // Does not load when already loading + controller.loading = true; + loadSpy.mockClear(); + controller.control.settings.onFocus(); + expect(loadSpy).not.toHaveBeenCalled(); + }); + + it("attaches scroll listener once on dropdown open", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + expect(controller.scrollAttached).toBe(false); + + const addEventListenerSpy = jest.spyOn( + controller.control.dropdown_content, + "addEventListener", + ); + + controller.control.settings.onDropdownOpen(); + expect(controller.scrollAttached).toBe(true); + expect(addEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function)); + + // Does not attach multiple times + controller.control.settings.onDropdownOpen(); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("infinite scroll (#fetchNextPage)", () => { + it("initializes pagination infrastructure", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + expect(controller._fetchOptions).toBeDefined(); + expect(typeof controller._fetchOptions).toBe("function"); + expect(controller.lastQuery).toBe(""); + expect(controller.control.settings.onDropdownOpen).toBeDefined(); + }); + + it("manages pagination state correctly", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + + // Initial state + expect(controller.page).toBe(1); + expect(controller.hasMore).toBe(true); + expect(controller.loading).toBe(false); + + // Can increment page + controller.page += 1; + expect(controller.page).toBe(2); + + // Can update hasMore flag + controller.hasMore = false; + expect(controller.hasMore).toBe(false); + + // Can track loading + controller.loading = true; + expect(controller.loading).toBe(true); + }); + + it("provides dropdown element access for scroll detection", async () => { + document.body.innerHTML = ` + + `; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const select = document.querySelector("select"); + const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + + expect(controller).not.toBeNull(); + expect(controller.control.dropdown_content).toBeDefined(); + + const dropdown = controller.control.dropdown_content; + expect(typeof dropdown.scrollTop).toBe("number"); + expect(typeof dropdown.clientHeight).toBe("number"); + expect(typeof dropdown.scrollHeight).toBe("number"); + }); + }); +}); From b58834b11f3d15e8cf2f3abe7a23402a9378a887 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Sun, 25 Jan 2026 19:13:43 +0500 Subject: [PATCH 04/11] Fix failing specs --- jest.config.js | 2 +- ...ry_fee_with_tax_report_by_producer_spec.rb | 26 +++++-------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/jest.config.js b/jest.config.js index 513257c467..2e451b9051 100644 --- a/jest.config.js +++ b/jest.config.js @@ -179,7 +179,7 @@ module.exports = { // transform: { "\\.[jt]sx?$": "babel-jest" }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - transformIgnorePatterns: ["/node_modules/(?!(stimulus|tom-select)/)"], + transformIgnorePatterns: ["/node_modules/(?!(stimulus.+|tom-select)/)"], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, diff --git a/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb b/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb index 002e0a7247..2218908fac 100644 --- a/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb +++ b/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb @@ -296,11 +296,8 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do end it "should filter by distributor and order cycle" do - page.find("#s2id_autogen1").click - find('li', text: distributor.name).click # selects Distributor - - page.find("#s2id_q_order_cycle_id_in").click - find('li', text: order_cycle.name).click + tomselect_multiselect distributor.name, from: 'q[distributor_id_in][]' + tomselect_multiselect order_cycle.name, from: 'q[order_cycle_id_in][]' run_report expect(page.find("table.report__table thead tr")).to have_content(table_header) @@ -455,9 +452,6 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do } context "filtering" do - let(:fee_name_selector){ "#s2id_q_enterprise_fee_id_in" } - let(:fee_owner_selector){ "#s2id_q_enterprise_fee_owner_id_in" } - let(:summary_row_after_filtering_by_fee_name){ [cost_of_produce1, "TOTAL", "120.0", "4.8", "124.8"].join(" ") } @@ -471,11 +465,8 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do end it "should filter by distributor and order cycle" do - page.find("#s2id_autogen1").click - find('li', text: distributor.name).click # selects Distributor - - page.find("#s2id_q_order_cycle_id_in").click - find('li', text: order_cycle3.name).click + tomselect_multiselect distributor.name, from: 'q[distributor_id_in][]' + tomselect_multiselect order_cycle3.name, from: 'q[order_cycle_id_in][]' run_report expect(page.find("table.report__table thead tr")).to have_content(table_header) @@ -504,8 +495,7 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do end it "should filter by producer" do - page.find("#s2id_supplier_id_in").click - find('li', text: supplier2.name).click + tomselect_multiselect supplier2.name, from: 'supplier_id_in[]' run_report expect(page.find("table.report__table thead tr")).to have_content(table_header) @@ -528,8 +518,7 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do end it "should filter by fee name" do - page.find(fee_name_selector).click - find('li', text: supplier_fees.name).click + tomselect_multiselect supplier_fees.name, from: 'q[enterprise_fee_id_in][]' run_report @@ -557,8 +546,7 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do end it "should filter by fee owner" do - page.find(fee_owner_selector).click - find('li', text: supplier.name).click + tomselect_multiselect supplier.name, from: 'q[enterprise_fee_owner_id_in][]' run_report expect(page.find("table.report__table thead tr")).to have_content(table_header) From 5f31baa022703fa5052438094ee012e319a1f28f Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Sun, 1 Feb 2026 19:32:18 +0500 Subject: [PATCH 05/11] Refactor TomSelectController to enhance remote data loading and update SearchableDropdownComponent options --- .../searchable_dropdown_component.rb | 5 +- .../controllers/tom_select_controller.js | 164 ++----- .../stimulus/tom_select_controller_test.js | 438 ++++++++---------- 3 files changed, 226 insertions(+), 381 deletions(-) diff --git a/app/components/searchable_dropdown_component.rb b/app/components/searchable_dropdown_component.rb index 821bf89979..c6a6852edf 100644 --- a/app/components/searchable_dropdown_component.rb +++ b/app/components/searchable_dropdown_component.rb @@ -46,8 +46,9 @@ class SearchableDropdownComponent < ViewComponent::Base end def tom_select_options_value - plugins = remove_search_plugin? ? [] : ['dropdown_input'] - multiple ? plugins << 'remove_button' : plugins + plugins = ['virtual_scroll'] + plugins << 'dropdown_input' unless remove_search_plugin? + plugins << 'remove_button' if multiple { plugins:, diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js index 81651f038b..e64bb60939 100644 --- a/app/webpacker/controllers/tom_select_controller.js +++ b/app/webpacker/controllers/tom_select_controller.js @@ -40,143 +40,53 @@ export default class extends Controller { return optionsArray.find((option) => [null, ""].includes(option.value))?.text; } + #buildUrl(query, page = 1) { + const url = new URL(this.remoteUrlValue, window.location.origin); + url.searchParams.set("q", query); + url.searchParams.set("page", page); + return url.toString(); + } + + #fetchOptions(query, callback) { + const url = this.control.getUrl(query); + + fetch(url) + .then((response) => response.json()) + .then((json) => { + /** + * Expected API shape: + * { + * results: [{ value, label }], + * pagination: { more: boolean } + * } + */ + if (json.pagination?.more) { + const currentUrl = new URL(url); + const currentPage = parseInt(currentUrl.searchParams.get("page") || "1"); + const nextUrl = this.#buildUrl(query, currentPage + 1); + this.control.setNextUrl(query, nextUrl); + } + + callback(json.results || []); + }) + .catch(() => { + callback(); + }); + } + #addRemoteOptions(options) { - // --- Pagination & request state --- - this.page = 1; - this.hasMore = true; - this.loading = false; - this.lastQuery = ""; - this.scrollAttached = false; - - const buildUrl = (query) => { - const url = new URL(this.remoteUrlValue, window.location.origin); - url.searchParams.set("q", query); - url.searchParams.set("page", this.page); - return url; + options.firstUrl = (query) => { + return this.#buildUrl(query, 1); }; - /** - * Shared remote fetch handler. - * Owns: - * - request lifecycle - * - pagination state - * - loading UI when appending - */ - const fetchOptions = ({ query, append = false, callback }) => { - if (this.loading || !this.hasMore) { - callback?.(); - return; - } - - this.loading = true; - - const dropdown = this.control?.dropdown_content; - const previousScrollTop = dropdown?.scrollTop; - let loader; - - /** - * When appending (infinite scroll), TomSelect does NOT - * manage loading UI automatically — we must do it manually. - */ - if (append && dropdown) { - loader = this.control.render("loading"); - dropdown.appendChild(loader); - this.control.wrapper.classList.add(this.control.settings.loadingClass); - } - - fetch(buildUrl(query)) - .then((response) => response.json()) - .then((json) => { - /** - * Expected API shape: - * { - * results: [{ value, label }], - * pagination: { more: boolean } - * } - */ - this.hasMore = Boolean(json.pagination?.more); - this.page += 1; - - const results = json.results || []; - - if (append && dropdown) { - this.control.addOptions(results); - this.control.refreshOptions(false); - /** - * Preserve scroll position so newly appended - * options don’t cause visual jumping. - */ - requestAnimationFrame(() => { - dropdown.scrollTop = previousScrollTop; - }); - } else { - callback?.(results); - } - }) - .catch(() => { - callback?.(); - }) - .finally(() => { - this.loading = false; - - if (append && loader) { - this.control.wrapper.classList.remove(this.control.settings.loadingClass); - loader.remove(); - } - }); - }; - - options.load = function (query, callback) { - fetchOptions({ query, callback }); - }.bind(this); - - options.onType = function (query) { - if (query === this.lastQuery) return; - - this.lastQuery = query; - this.page = 1; - this.hasMore = true; - }.bind(this); - - options.onDropdownOpen = function () { - if (this.scrollAttached) return; - this.scrollAttached = true; - - const dropdown = this.control.dropdown_content; - - dropdown.addEventListener( - "scroll", - function () { - const nearBottom = - dropdown.scrollTop + dropdown.clientHeight >= dropdown.scrollHeight - 20; - - if (nearBottom) { - this.#fetchNextPage(); - } - }.bind(this), - ); - }.bind(this); + options.load = this.#fetchOptions.bind(this); options.onFocus = function () { - if (this.loading) return; - - this.lastQuery = ""; this.control.load("", () => {}); }.bind(this); options.valueField = "value"; options.labelField = "label"; options.searchField = "label"; - - this._fetchOptions = fetchOptions; - } - - #fetchNextPage() { - if (this.loading || !this.hasMore) return; - - this._fetchOptions({ - query: this.lastQuery, - append: true, - }); } } diff --git a/spec/javascripts/stimulus/tom_select_controller_test.js b/spec/javascripts/stimulus/tom_select_controller_test.js index f53bae3ff7..cba45dfede 100644 --- a/spec/javascripts/stimulus/tom_select_controller_test.js +++ b/spec/javascripts/stimulus/tom_select_controller_test.js @@ -3,7 +3,78 @@ */ import { Application } from "stimulus"; -import tom_select_controller from "../../../app/webpacker/controllers/tom_select_controller.js"; +import { fireEvent, waitFor } from "@testing-library/dom"; +import tom_select_controller from "controllers/tom_select_controller"; + +/* ------------------------------------------------------------------ + * Helpers + * ------------------------------------------------------------------ */ + +const buildResults = (count, start = 1) => + Array.from({ length: count }, (_, i) => ({ + value: String(start + i), + label: `Option ${start + i}`, + })); + +const setupDOM = (html) => { + document.body.innerHTML = html; +}; + +const getSelect = () => document.getElementById("select"); +const getTomSelect = () => getSelect().tomselect; + +const openDropdown = () => fireEvent.click(document.getElementById("select-ts-control")); + +const mockRemoteFetch = (...responses) => { + responses.forEach((response) => { + fetch.mockResolvedValueOnce({ + json: async () => response, + }); + }); +}; + +const mockDropdownScroll = ( + dropdown, + { scrollHeight = 1000, clientHeight = 300, scrollTop = 700 } = {}, +) => { + Object.defineProperty(dropdown, "scrollHeight", { + configurable: true, + value: scrollHeight, + }); + + Object.defineProperty(dropdown, "clientHeight", { + configurable: true, + value: clientHeight, + }); + + Object.defineProperty(dropdown, "scrollTop", { + configurable: true, + writable: true, + value: scrollTop, + }); + + fireEvent.scroll(dropdown); +}; + +/* ------------------------------------------------------------------ + * Expectation helpers + * ------------------------------------------------------------------ */ + +const expectOptionsCount = (count) => { + expect(document.querySelectorAll('.ts-dropdown-content [role="option"]').length).toBe(count); +}; + +const expectDropdownToContain = (text) => { + expect(document.querySelector(".ts-dropdown-content")?.textContent).toContain(text); +}; + +const expectEmptyDropdown = () => { + expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe(""); +}; + +/* ------------------------------------------------------------------ + * Specs + * ------------------------------------------------------------------ */ describe("TomSelectController", () => { let application; @@ -14,12 +85,6 @@ describe("TomSelectController", () => { }); beforeEach(() => { - global.requestAnimationFrame = jest.fn((cb) => { - cb(); - return 1; - }); - - // Mock fetch for remote data tests global.fetch = jest.fn(); }); @@ -28,291 +93,160 @@ describe("TomSelectController", () => { jest.clearAllMocks(); }); - describe("basic initialization", () => { - it("initializes TomSelect with default options and settings", async () => { - document.body.innerHTML = ` - + - `; - - await new Promise((resolve) => setTimeout(resolve, 0)); - - const select = document.getElementById("test-select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - expect(controller.control).toBeDefined(); - expect(controller.control.settings.maxItems).toBe(1); - expect(controller.control.settings.maxOptions).toBeNull(); - expect(controller.control.settings.allowEmptyOption).toBe(true); - expect(controller.control.settings.placeholder).toBe("Choose an option"); - expect(controller.control.settings.plugins).toContain("dropdown_input"); - expect(controller.control.settings.onItemAdd).toBeDefined(); + `); }); - it("uses empty option text as placeholder when no placeholder value provided", async () => { - document.body.innerHTML = ` - - `; + it("initializes TomSelect with default options", () => { + const settings = getTomSelect().settings; - await new Promise((resolve) => setTimeout(resolve, 0)); - - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - expect(controller.control.settings.placeholder).toBe("-- Default Placeholder --"); - }); - - it("accepts custom options via data attribute", async () => { - document.body.innerHTML = ` - - `; - - await new Promise((resolve) => setTimeout(resolve, 0)); - - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - expect(controller.control.settings.maxItems).toBe(3); - expect(controller.control.settings.create).toBe(true); - }); - - it("cleans up on disconnect", async () => { - document.body.innerHTML = ` - - `; - - await new Promise((resolve) => setTimeout(resolve, 0)); - - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - const destroySpy = jest.spyOn(controller.control, "destroy"); - controller.disconnect(); - - expect(destroySpy).toHaveBeenCalled(); + expect(settings.placeholder).toBe("Default Option"); + expect(settings.maxItems).toBe(1); + expect(settings.plugins).toEqual(["dropdown_input"]); + expect(settings.allowEmptyOption).toBe(true); }); }); - describe("remote data loading (#addRemoteOptions)", () => { - it("configures remote loading with proper field and pagination setup", async () => { - document.body.innerHTML = ` - - + data-tom-select-placeholder-value="Choose an option" + data-tom-select-options-value='{"maxItems": 3, "plugins": ["remove_button"]}' + > + + - `; - - await new Promise((resolve) => setTimeout(resolve, 0)); - - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - expect(controller.page).toBe(1); - expect(controller.hasMore).toBe(true); - expect(controller.loading).toBe(false); - expect(controller.scrollAttached).toBe(false); - expect(controller.control.settings.valueField).toBe("value"); - expect(controller.control.settings.labelField).toBe("label"); - expect(controller.control.settings.searchField).toBe("label"); + `); }); - it("resets pagination on new query but preserves on same query", async () => { - document.body.innerHTML = ` - - - - `; - - await new Promise((resolve) => setTimeout(resolve, 0)); - - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - - controller.page = 5; - controller.hasMore = false; - controller.lastQuery = "old"; - - // Same query preserves state - controller.control.settings.onType("old"); - expect(controller.page).toBe(5); - - // New query resets state - controller.control.settings.onType("new"); - expect(controller.page).toBe(1); - expect(controller.hasMore).toBe(true); - expect(controller.lastQuery).toBe("new"); + data-tom-select-options-value='{"plugins":["virtual_scroll"]}' + data-tom-select-remote-url-value="https://ofn-tests.com/api/search" + > + `); }); - it("loads initial data on focus when not loading", async () => { - global.fetch.mockResolvedValue({ - json: jest.fn().mockResolvedValue({ - results: [], - pagination: { more: false }, - }), + it("configures remote loading callbacks", () => { + const settings = getTomSelect().settings; + + expect(settings.valueField).toBe("value"); + expect(settings.labelField).toBe("label"); + 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 () => { + mockRemoteFetch({ + results: buildResults(1), + pagination: { more: false }, }); - document.body.innerHTML = ` - - `; + openDropdown(); - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=&page=1")); - expect(controller).not.toBeNull(); - - const loadSpy = jest.spyOn(controller.control, "load"); - controller.control.settings.onFocus(); - - expect(loadSpy).toHaveBeenCalledWith("", expect.any(Function)); - expect(controller.lastQuery).toBe(""); - - // Does not load when already loading - controller.loading = true; - loadSpy.mockClear(); - controller.control.settings.onFocus(); - expect(loadSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expectOptionsCount(1); + expectDropdownToContain("Option 1"); + }); }); - it("attaches scroll listener once on dropdown open", async () => { - document.body.innerHTML = ` - - `; + it("fetches remote options using search query", async () => { + const appleOption = { value: "apple", label: "Apple" }; + mockRemoteFetch({ + results: [...buildResults(1), appleOption], + pagination: { more: false }, + }); - await new Promise((resolve) => setTimeout(resolve, 0)); + openDropdown(); - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + await waitFor(() => { + expectOptionsCount(2); + }); - expect(controller).not.toBeNull(); - expect(controller.scrollAttached).toBe(false); + mockRemoteFetch({ + results: [appleOption], + pagination: { more: false }, + }); - const addEventListenerSpy = jest.spyOn( - controller.control.dropdown_content, - "addEventListener", + fireEvent.input(document.getElementById("select-ts-control"), { + target: { value: "apple" }, + }); + + await waitFor(() => + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=apple&page=1")), ); - controller.control.settings.onDropdownOpen(); - expect(controller.scrollAttached).toBe(true); - expect(addEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function)); - - // Does not attach multiple times - controller.control.settings.onDropdownOpen(); - expect(addEventListenerSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe("infinite scroll (#fetchNextPage)", () => { - it("initializes pagination infrastructure", async () => { - document.body.innerHTML = ` - - `; - - await new Promise((resolve) => setTimeout(resolve, 0)); - - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - expect(controller._fetchOptions).toBeDefined(); - expect(typeof controller._fetchOptions).toBe("function"); - expect(controller.lastQuery).toBe(""); - expect(controller.control.settings.onDropdownOpen).toBeDefined(); + await waitFor(() => { + expectOptionsCount(1); + expectDropdownToContain("Apple"); + }); }); - it("manages pagination state correctly", async () => { - document.body.innerHTML = ` - - `; + it("loads next page on scroll (infinite scroll)", async () => { + mockRemoteFetch( + { + results: buildResults(30), + pagination: { more: true }, + }, + { + results: buildResults(1, 31), + pagination: { more: false }, + }, + ); - await new Promise((resolve) => setTimeout(resolve, 0)); + openDropdown(); - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); + await waitFor(() => { + expectOptionsCount(30); + }); - expect(controller).not.toBeNull(); + const dropdown = document.querySelector(".ts-dropdown-content"); + mockDropdownScroll(dropdown); - // Initial state - expect(controller.page).toBe(1); - expect(controller.hasMore).toBe(true); - expect(controller.loading).toBe(false); + await waitFor(() => { + expectOptionsCount(31); + }); - // Can increment page - controller.page += 1; - expect(controller.page).toBe(2); - - // Can update hasMore flag - controller.hasMore = false; - expect(controller.hasMore).toBe(false); - - // Can track loading - controller.loading = true; - expect(controller.loading).toBe(true); + expect(fetch).toHaveBeenCalledTimes(2); }); - it("provides dropdown element access for scroll detection", async () => { - document.body.innerHTML = ` - - `; + it("handles fetch errors gracefully", async () => { + fetch.mockRejectedValueOnce(new Error("Network error")); - await new Promise((resolve) => setTimeout(resolve, 0)); + openDropdown(); - const select = document.querySelector("select"); - const controller = application.getControllerForElementAndIdentifier(select, "tom-select"); - - expect(controller).not.toBeNull(); - expect(controller.control.dropdown_content).toBeDefined(); - - const dropdown = controller.control.dropdown_content; - expect(typeof dropdown.scrollTop).toBe("number"); - expect(typeof dropdown.clientHeight).toBe("number"); - expect(typeof dropdown.scrollHeight).toBe("number"); + await waitFor(() => { + expectEmptyDropdown(); + }); }); }); }); From 8eb9709a04866448d1bde820d6ede71428709149 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Sun, 1 Feb 2026 19:45:39 +0500 Subject: [PATCH 06/11] Fix specs: Fix tom_select_options_value to conditionally include 'virtual_scroll' plugin based on remote URL presence --- app/components/searchable_dropdown_component.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/searchable_dropdown_component.rb b/app/components/searchable_dropdown_component.rb index c6a6852edf..67a785edaf 100644 --- a/app/components/searchable_dropdown_component.rb +++ b/app/components/searchable_dropdown_component.rb @@ -46,7 +46,8 @@ class SearchableDropdownComponent < ViewComponent::Base end def tom_select_options_value - plugins = ['virtual_scroll'] + plugins = [] + plugins << 'virtual_scroll' if @remote_url.present? plugins << 'dropdown_input' unless remove_search_plugin? plugins << 'remove_button' if multiple From 99e238d92dbdebab890d86a5e1bdaf805435761d Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Wed, 4 Feb 2026 02:25:41 +0500 Subject: [PATCH 07/11] Improve reports AJAX search safety, permissions, and TomSelect UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sanitize AJAX search queries to safely support wildcard searches (ILIKE) - Centralize reports search actions in Spree::Ability and reuse across roles - Fix TomSelect remote loading to fetch on dropdown open and typing - Surface HTTP errors in TomSelect via showHttpError and improve error handling - Update dropdown behavior to show proper “no results” feedback - Move reports AJAX specs to request specs and expand pagination coverage - Simplify searchable dropdown component attribute passing --- .../searchable_dropdown_component.html.haml | 2 +- .../concerns/reports/ajax_search.rb | 9 +- app/models/spree/ability.rb | 10 +- .../controllers/tom_select_controller.js | 27 +- .../admin/reports_controller_spec.rb | 113 -------- .../stimulus/tom_select_controller_test.js | 53 +++- spec/requests/admin/reports_ajax_api_spec.rb | 271 ++++++++++++++++++ 7 files changed, 359 insertions(+), 126 deletions(-) create mode 100644 spec/requests/admin/reports_ajax_api_spec.rb diff --git a/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml index 85529d4c89..50d574d360 100644 --- a/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml +++ b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml @@ -1,4 +1,4 @@ - if uses_form_builder? = f.select name, options, { selected: selected_option, include_blank:, multiple: }, class: classes, data:, 'aria-label': aria_label, **other_attrs - else - = select_tag name, options_for_select(options, selected_option), include_blank: include_blank, multiple: multiple, class: classes, data: data, 'aria-label': aria_label, **other_attrs + = select_tag name, options_for_select(options, selected_option), include_blank:, multiple:, class: classes, data:, 'aria-label': aria_label, **other_attrs diff --git a/app/controllers/concerns/reports/ajax_search.rb b/app/controllers/concerns/reports/ajax_search.rb index 7194dc77a7..03df572a48 100644 --- a/app/controllers/concerns/reports/ajax_search.rb +++ b/app/controllers/concerns/reports/ajax_search.rb @@ -62,13 +62,16 @@ module Reports search_term = params[:q] return query if search_term.blank? + escaped_search_term = ActiveRecord::Base.sanitize_sql_like(search_term) + pattern = "%#{escaped_search_term}%" + # Handle different model types if query.model == OrderCycle - query.where("order_cycles.name ILIKE ?", "%#{search_term}%") + query.where("order_cycles.name ILIKE ?", pattern) elsif query.model == Customer - query.where("customers.email ILIKE ?", "%#{search_term}%") + query.where("customers.email ILIKE ?", pattern) else - query.where("name ILIKE ?", "%#{search_term}%") + query.where("name ILIKE ?", pattern) end end diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index 654b76a781..4695559945 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -6,6 +6,11 @@ module Spree class Ability include CanCan::Ability + REPORTS_SEARCH_ACTIONS = [ + :search_enterprise_fees, :search_enterprise_fee_owners, :search_distributors, + :search_suppliers, :search_order_cycles, :search_order_customers + ].freeze + def initialize(user) clear_aliased_actions @@ -260,8 +265,7 @@ module Spree can [:admin, :index, :import], ::Admin::DfcProductImportsController # Reports page - can [:admin, :index, :show, :create, :search_enterprise_fees, :search_enterprise_fee_owners, - :search_distributors, :search_suppliers, :search_order_cycles, :search_order_customers], + can [:admin, :index, :show, :create, *REPORTS_SEARCH_ACTIONS], ::Admin::ReportsController can [:admin, :show, :create, :customers, :orders_and_distributors, :group_buys, :payments, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, @@ -394,7 +398,7 @@ module Spree end # Reports page - can [:admin, :index, :show, :create], ::Admin::ReportsController + can [:admin, :index, :show, :create, *REPORTS_SEARCH_ACTIONS], ::Admin::ReportsController can [:admin, :customers, :group_buys, :sales_tax, :payments, :orders_and_distributors, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :xero_invoices, :enterprise_fee_summary, :bulk_coop], :report diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js index e64bb60939..fcc6b72e02 100644 --- a/app/webpacker/controllers/tom_select_controller.js +++ b/app/webpacker/controllers/tom_select_controller.js @@ -1,5 +1,6 @@ import { Controller } from "stimulus"; import TomSelect from "tom-select/dist/esm/tom-select.complete"; +import showHttpError from "../../webpacker/js/services/show_http_error"; export default class extends Controller { static values = { @@ -51,7 +52,13 @@ export default class extends Controller { const url = this.control.getUrl(query); fetch(url) - .then((response) => response.json()) + .then((response) => { + if (!response.ok) { + showHttpError(response.status); + throw response; + } + return response.json(); + }) .then((json) => { /** * Expected API shape: @@ -69,12 +76,15 @@ export default class extends Controller { callback(json.results || []); }) - .catch(() => { + .catch((error) => { callback(); + console.error(error); }); } #addRemoteOptions(options) { + this.openedByClick = false; + options.firstUrl = (query) => { return this.#buildUrl(query, 1); }; @@ -85,6 +95,19 @@ export default class extends Controller { this.control.load("", () => {}); }.bind(this); + options.onDropdownOpen = function () { + this.openedByClick = true; + }.bind(this); + + options.onType = function () { + this.openedByClick = false; + }.bind(this); + + // As per TomSelect source code, no result feedback after API call is shown when this callback returns true. + options.shouldLoad = function (query) { + return this.openedByClick || query.length > 0; + }.bind(this); + options.valueField = "value"; options.labelField = "label"; options.searchField = "label"; diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb index f38f6b837e..6fbf75f98e 100644 --- a/spec/controllers/admin/reports_controller_spec.rb +++ b/spec/controllers/admin/reports_controller_spec.rb @@ -368,117 +368,4 @@ RSpec.describe Admin::ReportsController do end end end - - context "AJAX Search" do - let(:enterprise_fee1) { - create(:enterprise_fee, name: "Delivery Fee", enterprise: distributor1) - } - let(:enterprise_fee2) { create(:enterprise_fee, name: "Admin Fee", enterprise: distributor2) } - - before do - controller_login_as_admin - orderA1.adjustments.create!( - originator: enterprise_fee1, - label: "Delivery Fee", - amount: 5.0, - state: "finalized", - order: orderA1 - ) - orderB1.adjustments.create!( - originator: enterprise_fee2, - label: "Admin Fee", - amount: 3.0, - state: "finalized", - order: orderB1 - ) - end - - describe "#search_enterprise_fees" do - it "returns paginated JSON with enterprise fees ordered by name" do - spree_get( - :search_enterprise_fees, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - ) - - expect(response).to have_http_status(:ok) - json_response = response.parsed_body - - names = json_response["results"].pluck("label") - expect(names).to eq(['Admin Fee', 'Delivery Fee']) - end - end - - describe "#search_enterprise_fee_owners" do - it "returns paginated JSON with unique enterprise owners ordered by name" do - distributor1.update!(name: "Zebra Farm") - distributor2.update!(name: "Alpha Market") - - spree_get( - :search_enterprise_fee_owners, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - ) - - expect(response).to have_http_status(:ok) - json_response = response.parsed_body - - names = json_response["results"].pluck("label") - expect(names).to eq(['Alpha Market', 'Zebra Farm']) - end - end - - describe "#search_order_customers" do - it "filters customers by email and returns paginated results" do - customer1 = create(:customer, email: "alice@example.com", enterprise: distributor1) - customer2 = create(:customer, email: "bob@example.com", enterprise: distributor1) - orderA1.update!(customer: customer1) - orderA2.update!(customer: customer2) - - spree_get( - :search_order_customers, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - q: "alice" - ) - - json_response = response.parsed_body - expect(json_response["results"].pluck("label")).to eq(["alice@example.com"]) - end - end - - describe "#search_order_cycles" do - it "filters order cycles by name and orders by close date" do - ocA.update!(name: "Winter Market") - ocB.update!(name: "Summer Market") - - spree_get( - :search_order_cycles, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - q: "Winter" - ) - - json_response = response.parsed_body - expect(json_response["results"].pluck("label")).to eq(["Winter Market"]) - end - end - - describe "#search_distributors" do - it "filters distributors by name" do - distributor1.update!(name: "Alpha Farm") - distributor2.update!(name: "Beta Market") - - spree_get( - :search_distributors, - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - q: "Alpha" - ) - - json_response = response.parsed_body - expect(json_response["results"].pluck("label")).to eq(["Alpha Farm"]) - end - end - end end diff --git a/spec/javascripts/stimulus/tom_select_controller_test.js b/spec/javascripts/stimulus/tom_select_controller_test.js index cba45dfede..f853e6dc94 100644 --- a/spec/javascripts/stimulus/tom_select_controller_test.js +++ b/spec/javascripts/stimulus/tom_select_controller_test.js @@ -5,6 +5,12 @@ import { Application } from "stimulus"; import { fireEvent, waitFor } from "@testing-library/dom"; import tom_select_controller from "controllers/tom_select_controller"; +import showHttpError from "js/services/show_http_error"; + +jest.mock("js/services/show_http_error", () => ({ + __esModule: true, + default: jest.fn(), +})); /* ------------------------------------------------------------------ * Helpers @@ -28,6 +34,7 @@ const openDropdown = () => fireEvent.click(document.getElementById("select-ts-co const mockRemoteFetch = (...responses) => { responses.forEach((response) => { fetch.mockResolvedValueOnce({ + ok: true, json: async () => response, }); }); @@ -68,10 +75,11 @@ const expectDropdownToContain = (text) => { expect(document.querySelector(".ts-dropdown-content")?.textContent).toContain(text); }; -const expectEmptyDropdown = () => { - expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe(""); +const expectDropdownWithNoResults = () => { + expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe("No results found"); }; + /* ------------------------------------------------------------------ * Specs * ------------------------------------------------------------------ */ @@ -86,6 +94,7 @@ describe("TomSelectController", () => { beforeEach(() => { global.fetch = jest.fn(); + global.I18n = { t: (key) => key }; }); afterEach(() => { @@ -240,13 +249,49 @@ describe("TomSelectController", () => { }); it("handles fetch errors gracefully", async () => { - fetch.mockRejectedValueOnce(new Error("Network error")); + fetch.mockRejectedValueOnce(new Error("Fetch error")); openDropdown(); await waitFor(() => { - expectEmptyDropdown(); + expectDropdownWithNoResults(); }); + + expect(showHttpError).not.toHaveBeenCalled(); + }); + + it("displays HTTP error on failure", async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + }); + + openDropdown(); + + await waitFor(() => { + expect(showHttpError).toHaveBeenCalledWith(500); + }); + + expectDropdownWithNoResults(); + }); + + it("controls loading behavior based on user interaction", () => { + const settings = getTomSelect().settings; + + // Initial state: openedByClick is false, query is empty + expect(settings.shouldLoad("")).toBe(false); + + // Simulating opening the dropdown + settings.onDropdownOpen(); + expect(settings.shouldLoad("")).toBe(true); + + // Simulating typing + settings.onType(); + expect(settings.shouldLoad("")).toBe(false); + + // Query present + expect(settings.shouldLoad("a")).toBe(true); }); }); }); diff --git a/spec/requests/admin/reports_ajax_api_spec.rb b/spec/requests/admin/reports_ajax_api_spec.rb new file mode 100644 index 0000000000..37654fb18e --- /dev/null +++ b/spec/requests/admin/reports_ajax_api_spec.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +RSpec.describe "Admin Reports AJAX Search API" do + let(:bill_address) { create(:address) } + let(:ship_address) { create(:address) } + let(:instructions) { "pick up on thursday please" } + let(:coordinator1) { create(:distributor_enterprise) } + let(:supplier1) { create(:supplier_enterprise) } + let(:supplier2) { create(:supplier_enterprise) } + let(:supplier3) { create(:supplier_enterprise) } + let(:distributor1) { create(:distributor_enterprise) } + let(:distributor2) { create(:distributor_enterprise) } + let(:product1) { create(:product, price: 12.34, supplier_id: supplier1.id) } + let(:product2) { create(:product, price: 23.45, supplier_id: supplier2.id) } + let(:product3) { create(:product, price: 34.56, supplier_id: supplier3.id) } + + let(:ocA) { + create(:simple_order_cycle, coordinator: coordinator1, + distributors: [distributor1, distributor2], + suppliers: [supplier1, supplier2, supplier3], + variants: [product1.variants.first, product3.variants.first]) + } + let(:ocB) { + create(:simple_order_cycle, coordinator: coordinator1, + distributors: [distributor1, distributor2], + suppliers: [supplier1, supplier2, supplier3], + variants: [product2.variants.first]) + } + + let(:orderA1) do + order = create(:order, distributor: distributor1, bill_address:, + ship_address:, special_instructions: instructions, + order_cycle: ocA) + order.line_items << create(:line_item, variant: product1.variants.first) + order.line_items << create(:line_item, variant: product3.variants.first) + order.finalize! + order.save + order + end + + let(:orderA2) do + order = create(:order, distributor: distributor2, bill_address:, + ship_address:, special_instructions: instructions, + order_cycle: ocA) + order.line_items << create(:line_item, variant: product2.variants.first) + order.finalize! + order.save + order + end + + let(:orderB1) do + order = create(:order, distributor: distributor1, bill_address:, + ship_address:, special_instructions: instructions, + order_cycle: ocB) + order.line_items << create(:line_item, variant: product1.variants.first) + order.line_items << create(:line_item, variant: product3.variants.first) + order.finalize! + order.save + order + end + + context "AJAX Search" do + let(:enterprise_fee1) { + create(:enterprise_fee, name: "Delivery Fee", enterprise: distributor1) + } + let(:enterprise_fee2) { create(:enterprise_fee, name: "Admin Fee", enterprise: distributor2) } + + before do + login_as create(:admin_user) + orderA1.adjustments.create!( + originator: enterprise_fee1, + label: "Delivery Fee", + amount: 5.0, + state: "finalized", + order: orderA1 + ) + orderB1.adjustments.create!( + originator: enterprise_fee2, + label: "Admin Fee", + amount: 3.0, + state: "finalized", + order: orderB1 + ) + end + + describe "GET /admin/reports/search_enterprise_fees" do + it "returns paginated JSON with enterprise fees ordered by name" do + get "/admin/reports/search_enterprise_fees", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + } + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + names = json_response["results"].pluck("label") + expect(names).to eq(['Admin Fee', 'Delivery Fee']) + expect(json_response["pagination"]["more"]).to be false + end + + it "paginates results and sets more flag correctly with more than 30 records" do + create_list(:enterprise_fee, 35, enterprise: distributor1) do |fee, i| + index = (i + 1).to_s.rjust(2, "0") + fee.update!(name: "Fee #{index}") + orderA1.adjustments.create!( + originator: fee, + label: "Fee #{index}", + amount: 1.0, + state: "finalized", + order: orderA1 + ) + end + + get "/admin/reports/search_enterprise_fees", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + page: 1 + } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + + get "/admin/reports/search_enterprise_fees", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + page: 2 + } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(7) + expect(json_response["pagination"]["more"]).to be false + end + end + + describe "GET /admin/reports/search_enterprise_fee_owners" do + it "returns paginated JSON with unique enterprise owners ordered by name" do + distributor1.update!(name: "Zebra Farm") + distributor2.update!(name: "Alpha Market") + + get "/admin/reports/search_enterprise_fee_owners", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order + } + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + names = json_response["results"].pluck("label") + expect(names).to eq(['Alpha Market', 'Zebra Farm']) + expect(json_response["pagination"]["more"]).to be false + end + end + + describe "GET /admin/reports/search_order_customers" do + it "filters customers by email and returns paginated results" do + customer1 = create(:customer, email: "alice@example.com", enterprise: distributor1) + customer2 = create(:customer, email: "bob@example.com", enterprise: distributor1) + orderA1.update!(customer: customer1) + orderA2.update!(customer: customer2) + + get "/admin/reports/search_order_customers", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + q: "alice" + } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(["alice@example.com"]) + expect(json_response["pagination"]["more"]).to be false + end + + it "paginates customers and sets more flag correctly with more than 30 records" do + create_list(:customer, 35, enterprise: distributor1) do |customer, i| + customer.update!( + email: "customer#{(i + 1).to_s.rjust(2, '0')}@example.com" + ) + order = create( + :order, + distributor: distributor1, + order_cycle: ocA, + customer: customer + ) + order.line_items << create( + :line_item, + variant: product1.variants.first + ) + order.finalize! + end + + get "/admin/reports/search_order_customers", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + page: 1 + } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + + get "/admin/reports/search_order_customers", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + page: 2 + } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(5) + expect(json_response["pagination"]["more"]).to be false + end + end + + describe "GET /admin/reports/search_order_cycles" do + it "filters order cycles by name and orders by close date" do + ocA.update!(name: "Winter Market") + ocB.update!(name: "Summer Market") + + get "/admin/reports/search_order_cycles", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + q: "Winter" + } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(["Winter Market"]) + expect(json_response["pagination"]["more"]).to be false + end + end + + describe "GET /admin/reports/search_distributors" do + it "filters distributors by name" do + distributor1.update!(name: "Alpha Farm") + distributor2.update!(name: "Beta Market") + + get "/admin/reports/search_distributors", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + q: "Alpha" + } + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(["Alpha Farm"]) + expect(json_response["pagination"]["more"]).to be false + end + + it "paginates distributors and sets more flag correctly with more than 30 records" do + create_list(:distributor_enterprise, 35) + + get "/admin/reports/search_distributors", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + page: 1 + } + + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + + get "/admin/reports/search_distributors", params: { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order, + page: 2 + } + + json_response = response.parsed_body + expect(json_response["results"].length).to be > 0 + expect(json_response["pagination"]["more"]).to be false + end + end + end +end From 90c23d0245a3ea5085289f576c4145920d9a282a Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Wed, 4 Feb 2026 02:26:44 +0500 Subject: [PATCH 08/11] fix lint issue --- spec/javascripts/stimulus/tom_select_controller_test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/javascripts/stimulus/tom_select_controller_test.js b/spec/javascripts/stimulus/tom_select_controller_test.js index f853e6dc94..4f4705e222 100644 --- a/spec/javascripts/stimulus/tom_select_controller_test.js +++ b/spec/javascripts/stimulus/tom_select_controller_test.js @@ -79,7 +79,6 @@ const expectDropdownWithNoResults = () => { expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe("No results found"); }; - /* ------------------------------------------------------------------ * Specs * ------------------------------------------------------------------ */ From 9dcb3ec7488bb04147849c54b3d3495c020051da Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Wed, 4 Feb 2026 03:55:07 +0500 Subject: [PATCH 09/11] Refactor report routes to use scoped routes for better organization and readability --- ...prise_fees_with_tax_report_by_order.html.haml | 10 +++++----- ...se_fees_with_tax_report_by_producer.html.haml | 12 ++++++------ config/routes/admin.rb | 16 +++++++++------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml index 1001886995..c3aa10070e 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml @@ -7,7 +7,7 @@ options: [], selected_option: params.dig(:q, :distributor_id_in), multiple: true, - remote_url: admin_search_distributors_reports_url)) + remote_url: admin_reports_search_distributors_url)) .row .alpha.two.columns= label_tag nil, t(:report_customers_cycle) @@ -17,7 +17,7 @@ options: [], selected_option: params.dig(:q, :order_cycle_id_in), multiple: true, - remote_url: admin_search_order_cycles_reports_url)) + remote_url: admin_reports_search_order_cycles_url)) .row .alpha.two.columns= label_tag nil, t(:fee_name) @@ -27,7 +27,7 @@ options: [], selected_option: params.dig(:q, :enterprise_fee_id_in), multiple: true, - remote_url: admin_search_enterprise_fees_reports_url(search_url_query))) + remote_url: admin_reports_search_enterprise_fees_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:fee_owner) @@ -37,7 +37,7 @@ options: [], selected_option: params.dig(:q, :enterprise_fee_owner_id_in), multiple: true, - remote_url: admin_search_enterprise_fee_owners_reports_url(search_url_query))) + remote_url: admin_reports_search_enterprise_fee_owners_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:report_customers) @@ -47,4 +47,4 @@ options: [], selected_option: params.dig(:q, :customer_id_in), multiple: true, - remote_url: admin_search_order_customers_reports_url)) + remote_url: admin_reports_search_order_customers_url)) diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml index c71e92ae58..3eb41c1733 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml @@ -7,7 +7,7 @@ options: [], selected_option: params.dig(:q, :distributor_id_in), multiple: true, - remote_url: admin_search_distributors_reports_url)) + remote_url: admin_reports_search_distributors_url)) .row .alpha.two.columns= label_tag nil, t(:report_producers) .omega.fourteen.columns @@ -15,7 +15,7 @@ options: [], selected_option: params.dig(:supplier_id_in), multiple: true, - remote_url: admin_search_suppliers_reports_url)) + remote_url: admin_reports_search_suppliers_url)) .row .alpha.two.columns= label_tag nil, t(:report_customers_cycle) @@ -25,7 +25,7 @@ options: [], selected_option: params.dig(:q, :order_cycle_id_in), multiple: true, - remote_url: admin_search_order_cycles_reports_url)) + remote_url: admin_reports_search_order_cycles_url)) .row .alpha.two.columns= label_tag nil, t(:fee_name) .omega.fourteen.columns @@ -34,7 +34,7 @@ options: [], selected_option: params.dig(:q, :enterprise_fee_id_in), multiple: true, - remote_url: admin_search_enterprise_fees_reports_url(search_url_query))) + remote_url: admin_reports_search_enterprise_fees_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:fee_owner) @@ -44,7 +44,7 @@ options: [], selected_option: params.dig(:q, :enterprise_fee_owner_id_in), multiple: true, - remote_url: admin_search_enterprise_fee_owners_reports_url(search_url_query))) + remote_url: admin_reports_search_enterprise_fee_owners_url(search_url_query))) .row .alpha.two.columns= label_tag nil, t(:report_customers) @@ -54,4 +54,4 @@ options: [], selected_option: params.dig(:q, :customer_id_in), multiple: true, - remote_url: admin_search_order_customers_reports_url)) + remote_url: admin_reports_search_order_customers_url)) diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 68c60b29f9..305ef999f6 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -136,13 +136,15 @@ Openfoodnetwork::Application.routes.draw do put :resume, on: :member, format: :json end - get '/reports', to: 'reports#index', as: :reports - get '/reports/search_enterprise_fees', to: 'reports#search_enterprise_fees', as: :search_enterprise_fees_reports - get '/reports/search_enterprise_fee_owners', to: 'reports#search_enterprise_fee_owners', as: :search_enterprise_fee_owners_reports - get '/reports/search_distributors', to: 'reports#search_distributors', as: :search_distributors_reports - get '/reports/search_suppliers', to: 'reports#search_suppliers', as: :search_suppliers_reports - get '/reports/search_order_cycles', to: 'reports#search_order_cycles', as: :search_order_cycles_reports - get '/reports/search_order_customers', to: 'reports#search_order_customers', as: :search_order_customers_reports + scope :reports, as: :reports do + get '/', to: 'reports#index' + get '/search_enterprise_fees', to: 'reports#search_enterprise_fees', as: :search_enterprise_fees + get '/search_enterprise_fee_owners', to: 'reports#search_enterprise_fee_owners', as: :search_enterprise_fee_owners + get '/search_distributors', to: 'reports#search_distributors', as: :search_distributors + get '/search_suppliers', to: 'reports#search_suppliers', as: :search_suppliers + get '/search_order_cycles', to: 'reports#search_order_cycles', as: :search_order_cycles + get '/search_order_customers', to: 'reports#search_order_customers', as: :search_order_customers + end match '/reports/:report_type(/:report_subtype)', to: 'reports#show', via: :get, as: :report match '/reports/:report_type(/:report_subtype)', to: 'reports#create', via: :post end From 461fd00ccd420920e8063520d826643741851594 Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Sun, 8 Feb 2026 18:06:54 +0500 Subject: [PATCH 10/11] Add specs for non-linked fees by introducing specs for non-admin users. Plus include specs refactoring --- spec/requests/admin/reports_ajax_api_spec.rb | 309 +++++++++++-------- 1 file changed, 183 insertions(+), 126 deletions(-) diff --git a/spec/requests/admin/reports_ajax_api_spec.rb b/spec/requests/admin/reports_ajax_api_spec.rb index 37654fb18e..cb4f50c0c2 100644 --- a/spec/requests/admin/reports_ajax_api_spec.rb +++ b/spec/requests/admin/reports_ajax_api_spec.rb @@ -14,6 +14,9 @@ RSpec.describe "Admin Reports AJAX Search API" do let(:product2) { create(:product, price: 23.45, supplier_id: supplier2.id) } let(:product3) { create(:product, price: 34.56, supplier_id: supplier3.id) } + let(:enterprise_fee1) { create(:enterprise_fee, name: "Delivery Fee", enterprise: distributor1) } + let(:enterprise_fee2) { create(:enterprise_fee, name: "Admin Fee", enterprise: distributor2) } + let(:ocA) { create(:simple_order_cycle, coordinator: coordinator1, distributors: [distributor1, distributor2], @@ -59,167 +62,153 @@ RSpec.describe "Admin Reports AJAX Search API" do order end - context "AJAX Search" do - let(:enterprise_fee1) { - create(:enterprise_fee, name: "Delivery Fee", enterprise: distributor1) + let(:base_params) do + { + report_type: :enterprise_fee_summary, + report_subtype: :enterprise_fees_with_tax_report_by_order } - let(:enterprise_fee2) { create(:enterprise_fee, name: "Admin Fee", enterprise: distributor2) } + end + def create_adjustment(order, fee, amount) + order.adjustments.create!( + originator: fee, + label: fee.name, + amount:, + state: "finalized", + order: + ) + end + + context "when user is an admin" do before do login_as create(:admin_user) - orderA1.adjustments.create!( - originator: enterprise_fee1, - label: "Delivery Fee", - amount: 5.0, - state: "finalized", - order: orderA1 - ) - orderB1.adjustments.create!( - originator: enterprise_fee2, - label: "Admin Fee", - amount: 3.0, - state: "finalized", - order: orderB1 - ) + create_adjustment(orderA1, enterprise_fee1, 5.0) + create_adjustment(orderB1, enterprise_fee2, 3.0) end describe "GET /admin/reports/search_enterprise_fees" do - it "returns paginated JSON with enterprise fees ordered by name" do - get "/admin/reports/search_enterprise_fees", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - } + it "returns enterprise fees sorted alphabetically by name" do + get "/admin/reports/search_enterprise_fees", params: base_params expect(response).to have_http_status(:ok) json_response = response.parsed_body - names = json_response["results"].pluck("label") - expect(names).to eq(['Admin Fee', 'Delivery Fee']) + expect(json_response["results"].pluck("label")).to eq(['Admin Fee', 'Delivery Fee']) expect(json_response["pagination"]["more"]).to be false end - it "paginates results and sets more flag correctly with more than 30 records" do - create_list(:enterprise_fee, 35, enterprise: distributor1) do |fee, i| - index = (i + 1).to_s.rjust(2, "0") - fee.update!(name: "Fee #{index}") - orderA1.adjustments.create!( - originator: fee, - label: "Fee #{index}", - amount: 1.0, - state: "finalized", - order: orderA1 - ) + context "with more than 30 records" do + before do + create_list(:enterprise_fee, 35, enterprise: distributor1) do |fee, i| + index = (i + 1).to_s.rjust(2, "0") + fee.update!(name: "Fee #{index}") + create_adjustment(orderA1, fee, 1.0) + end end - get "/admin/reports/search_enterprise_fees", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - page: 1 - } + it "returns first page with 30 results and more flag as true" do + get "/admin/reports/search_enterprise_fees", params: base_params.merge(page: 1) - json_response = response.parsed_body - expect(json_response["results"].length).to eq(30) - expect(json_response["pagination"]["more"]).to be true + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + end - get "/admin/reports/search_enterprise_fees", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - page: 2 - } + it "returns remaining results on second page with more flag as false" do + get "/admin/reports/search_enterprise_fees", params: base_params.merge(page: 2) - json_response = response.parsed_body - expect(json_response["results"].length).to eq(7) - expect(json_response["pagination"]["more"]).to be false + json_response = response.parsed_body + expect(json_response["results"].length).to eq(7) + expect(json_response["pagination"]["more"]).to be false + end end end describe "GET /admin/reports/search_enterprise_fee_owners" do - it "returns paginated JSON with unique enterprise owners ordered by name" do + it "returns unique enterprise fee owners sorted alphabetically by name" do distributor1.update!(name: "Zebra Farm") distributor2.update!(name: "Alpha Market") - get "/admin/reports/search_enterprise_fee_owners", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order - } + get "/admin/reports/search_enterprise_fee_owners", params: base_params expect(response).to have_http_status(:ok) json_response = response.parsed_body - names = json_response["results"].pluck("label") - expect(names).to eq(['Alpha Market', 'Zebra Farm']) + expect(json_response["results"].pluck("label")).to eq(['Alpha Market', 'Zebra Farm']) expect(json_response["pagination"]["more"]).to be false end end describe "GET /admin/reports/search_order_customers" do - it "filters customers by email and returns paginated results" do - customer1 = create(:customer, email: "alice@example.com", enterprise: distributor1) - customer2 = create(:customer, email: "bob@example.com", enterprise: distributor1) + let!(:customer1) { create(:customer, email: "alice@example.com", enterprise: distributor1) } + let!(:customer2) { create(:customer, email: "bob@example.com", enterprise: distributor1) } + + before do orderA1.update!(customer: customer1) orderA2.update!(customer: customer2) + end - get "/admin/reports/search_order_customers", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - q: "alice" - } + it "returns all customers sorted by email" do + get "/admin/reports/search_order_customers", params: base_params + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(["alice@example.com", + "bob@example.com"]) + expect(json_response["pagination"]["more"]).to be false + end + + it "filters customers by email query" do + get "/admin/reports/search_order_customers", params: base_params.merge(q: "alice") json_response = response.parsed_body expect(json_response["results"].pluck("label")).to eq(["alice@example.com"]) expect(json_response["pagination"]["more"]).to be false end - it "paginates customers and sets more flag correctly with more than 30 records" do - create_list(:customer, 35, enterprise: distributor1) do |customer, i| - customer.update!( - email: "customer#{(i + 1).to_s.rjust(2, '0')}@example.com" - ) - order = create( - :order, - distributor: distributor1, - order_cycle: ocA, - customer: customer - ) - order.line_items << create( - :line_item, - variant: product1.variants.first - ) - order.finalize! + context "with more than 30 customers" do + before do + create_list(:customer, 35, enterprise: distributor1) do |customer, i| + customer.update!(email: "customer#{(i + 1).to_s.rjust(2, '0')}@example.com") + order = create(:order, distributor: distributor1, order_cycle: ocA, customer:) + order.line_items << create(:line_item, variant: product1.variants.first) + order.finalize! + end end - get "/admin/reports/search_order_customers", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - page: 1 - } + it "returns first page with 30 results and more flag as true" do + get "/admin/reports/search_order_customers", params: base_params.merge(page: 1) - json_response = response.parsed_body - expect(json_response["results"].length).to eq(30) - expect(json_response["pagination"]["more"]).to be true + json_response = response.parsed_body + expect(json_response["results"].length).to eq(30) + expect(json_response["pagination"]["more"]).to be true + end - get "/admin/reports/search_order_customers", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - page: 2 - } + it "returns remaining results on second page with more flag as false" do + get "/admin/reports/search_order_customers", params: base_params.merge(page: 2) - json_response = response.parsed_body - expect(json_response["results"].length).to eq(5) - expect(json_response["pagination"]["more"]).to be false + json_response = response.parsed_body + expect(json_response["results"].length).to eq(7) + expect(json_response["pagination"]["more"]).to be false + end end end describe "GET /admin/reports/search_order_cycles" do - it "filters order cycles by name and orders by close date" do + before do ocA.update!(name: "Winter Market") ocB.update!(name: "Summer Market") + end - get "/admin/reports/search_order_cycles", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - q: "Winter" - } + it "returns order cycles sorted by close date" do + get "/admin/reports/search_order_cycles", params: base_params + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq(["Summer Market", "Winter Market"]) + expect(json_response["pagination"]["more"]).to be false + end + + it "filters order cycles by name query" do + get "/admin/reports/search_order_cycles", params: base_params.merge(q: "Winter") json_response = response.parsed_body expect(json_response["results"].pluck("label")).to eq(["Winter Market"]) @@ -228,42 +217,110 @@ RSpec.describe "Admin Reports AJAX Search API" do end describe "GET /admin/reports/search_distributors" do - it "filters distributors by name" do + before do distributor1.update!(name: "Alpha Farm") distributor2.update!(name: "Beta Market") + end - get "/admin/reports/search_distributors", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - q: "Alpha" - } + it "filters distributors by name query" do + get "/admin/reports/search_distributors", params: base_params.merge(q: "Alpha") json_response = response.parsed_body expect(json_response["results"].pluck("label")).to eq(["Alpha Farm"]) expect(json_response["pagination"]["more"]).to be false end - it "paginates distributors and sets more flag correctly with more than 30 records" do - create_list(:distributor_enterprise, 35) + context "with more than 30 distributors" do + before { create_list(:distributor_enterprise, 35) } - get "/admin/reports/search_distributors", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - page: 1 - } + it "returns first page with 30 results and more flag as true" do + get "/admin/reports/search_distributors", params: base_params.merge(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 subsequent pages with more flag as false" do + get "/admin/reports/search_distributors", params: base_params.merge(page: 2) + + json_response = response.parsed_body + expect(json_response["results"].length).to be > 0 + expect(json_response["pagination"]["more"]).to be false + end + end + end + end + + context "when user is not an admin" do + before do + login_as distributor1.users.first + create_adjustment(orderA1, enterprise_fee1, 5.0) + create_adjustment(orderA2, enterprise_fee2, 3.0) + end + + describe "GET /admin/reports/search_enterprise_fees" do + it "returns only enterprise fees for user's managed enterprises" do + get "/admin/reports/search_enterprise_fees", params: base_params + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + expect(json_response["results"].pluck("label")).to eq(['Delivery Fee']) + expect(json_response["pagination"]["more"]).to be false + end + end + + describe "GET /admin/reports/search_enterprise_fee_owners" do + it "returns only enterprise fee owners for user's managed enterprises" do + get "/admin/reports/search_enterprise_fee_owners", params: base_params + + expect(response).to have_http_status(:ok) + json_response = response.parsed_body + + expect(json_response["results"].pluck("label")).to eq([distributor1.name]) + expect(json_response["pagination"]["more"]).to be false + end + end + + describe "GET /admin/reports/search_order_customers" do + it "returns only customers from user's managed enterprises" do + customer1 = create(:customer, email: "alice@example.com", enterprise: distributor1) + customer2 = create(:customer, email: "bob@example.com", enterprise: distributor1) + orderA1.update!(customer: customer1) + orderA2.update!(customer: customer2) + + get "/admin/reports/search_order_customers", params: base_params json_response = response.parsed_body - expect(json_response["results"].length).to eq(30) - expect(json_response["pagination"]["more"]).to be true + expect(json_response["results"].pluck("label")).to eq(["alice@example.com"]) + expect(json_response["pagination"]["more"]).to be false + end + end - get "/admin/reports/search_distributors", params: { - report_type: :enterprise_fee_summary, - report_subtype: :enterprise_fees_with_tax_report_by_order, - page: 2 - } + describe "GET /admin/reports/search_order_cycles" do + it "returns only order cycles accessible to user's managed enterprises" do + ocA.update!(name: "Winter Market") + ocB.update!(name: "Summer Market") + create(:simple_order_cycle, name: 'Autumn Market', coordinator: coordinator1, + distributors: [distributor2], + suppliers: [supplier1, supplier2, supplier3], + variants: [product2.variants.first]) + + get "/admin/reports/search_order_cycles", params: base_params json_response = response.parsed_body - expect(json_response["results"].length).to be > 0 + expect(json_response["results"].pluck("label")).to eq(["Summer Market", "Winter Market"]) + expect(json_response["pagination"]["more"]).to be false + end + end + + describe "GET /admin/reports/search_distributors" do + it "returns only user's managed distributors" do + get "/admin/reports/search_distributors", params: base_params + + json_response = response.parsed_body + expect(json_response["results"].pluck("label")).to eq([distributor1.name]) expect(json_response["pagination"]["more"]).to be false end end From 7c0586db7b9b53de1cd5396669ef1bb043dc834b Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Sun, 8 Feb 2026 18:29:55 +0500 Subject: [PATCH 11/11] Enhance comment for shouldLoad function --- app/webpacker/controllers/tom_select_controller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js index fcc6b72e02..3867fa3380 100644 --- a/app/webpacker/controllers/tom_select_controller.js +++ b/app/webpacker/controllers/tom_select_controller.js @@ -103,7 +103,9 @@ export default class extends Controller { this.openedByClick = false; }.bind(this); - // As per TomSelect source code, no result feedback after API call is shown when this callback returns true. + // As per TomSelect source code, Loading state is shown on the UI when this function returns true. + // By default it shows loading state only when there is some input in the search box. + // We want to show loading state on focus as well (when there is no input) to indicate that options are being loaded. options.shouldLoad = function (query) { return this.openedByClick || query.length > 0; }.bind(this);