diff --git a/app/components/searchable_dropdown_component.rb b/app/components/searchable_dropdown_component.rb
index 1f4d5f9e87..67a785edaf 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,33 @@ 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 = []
+ plugins << 'virtual_scroll' if @remote_url.present?
+ plugins << 'dropdown_input' unless remove_search_plugin?
+ plugins << 'remove_button' if multiple
+
+ {
+ 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..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 +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:, multiple:, class: classes, data:, 'aria-label': aria_label, **other_attrs
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..03df572a48
--- /dev/null
+++ b/app/controllers/concerns/reports/ajax_search.rb
@@ -0,0 +1,108 @@
+# 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?
+
+ 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 ?", pattern)
+ elsif query.model == Customer
+ query.where("customers.email ILIKE ?", pattern)
+ else
+ query.where("name ILIKE ?", pattern)
+ 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 { |label, value| { value:, label: } }
+ 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..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,7 +265,8 @@ module Spree
can [:admin, :index, :import], ::Admin::DfcProductImportsController
# Reports page
- can [:admin, :index, :show, :create], ::Admin::ReportsController
+ 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,
:packing, :enterprise_fee_summary, :bulk_coop, :suppliers], :report
@@ -392,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/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..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
@@ -1,23 +1,50 @@
+- 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
+ = render(SearchableDropdownComponent.new(form: f,
+ name: :distributor_id_in,
+ options: [],
+ selected_option: params.dig(:q, :distributor_id_in),
+ multiple: true,
+ remote_url: admin_reports_search_distributors_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})
+ = 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_reports_search_order_cycles_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})
+ = 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_reports_search_enterprise_fees_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})
+ = 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_reports_search_enterprise_fee_owners_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})
+ = render(SearchableDropdownComponent.new(form: f,
+ name: :customer_id_in,
+ options: [],
+ selected_option: params.dig(:q, :customer_id_in),
+ multiple: true,
+ 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 1c19788df6..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
@@ -1,27 +1,57 @@
+- 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
+ = render(SearchableDropdownComponent.new(form: f,
+ name: :distributor_id_in,
+ options: [],
+ selected_option: params.dig(:q, :distributor_id_in),
+ multiple: true,
+ remote_url: admin_reports_search_distributors_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
+ = render(SearchableDropdownComponent.new(name: :supplier_id_in,
+ options: [],
+ selected_option: params.dig(:supplier_id_in),
+ multiple: true,
+ remote_url: admin_reports_search_suppliers_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})
-
+ = 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_reports_search_order_cycles_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})
+ = 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_reports_search_enterprise_fees_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})
+ = 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_reports_search_enterprise_fee_owners_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})
+ = render(SearchableDropdownComponent.new(form: f,
+ name: :customer_id_in,
+ options: [],
+ selected_option: params.dig(:q, :customer_id_in),
+ multiple: true,
+ remote_url: admin_reports_search_order_customers_url))
diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js
index d1d3ade58a..3867fa3380 100644
--- a/app/webpacker/controllers/tom_select_controller.js
+++ b/app/webpacker/controllers/tom_select_controller.js
@@ -1,11 +1,16 @@
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 = { 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 +21,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 +40,78 @@ export default class extends Controller {
const optionsArray = [...this.element.options];
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) => {
+ if (!response.ok) {
+ showHttpError(response.status);
+ throw response;
+ }
+ return 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((error) => {
+ callback();
+ console.error(error);
+ });
+ }
+
+ #addRemoteOptions(options) {
+ this.openedByClick = false;
+
+ options.firstUrl = (query) => {
+ return this.#buildUrl(query, 1);
+ };
+
+ options.load = this.#fetchOptions.bind(this);
+
+ options.onFocus = function () {
+ 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, 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);
+
+ options.valueField = "value";
+ options.labelField = "label";
+ options.searchField = "label";
+ }
}
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index b80d340bd4..305ef999f6 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -136,7 +136,15 @@ Openfoodnetwork::Application.routes.draw do
put :resume, on: :member, format: :json
end
- get '/reports', to: 'reports#index', as: :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
diff --git a/jest.config.js b/jest.config.js
index 99c9c31e3b..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/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
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..4f4705e222
--- /dev/null
+++ b/spec/javascripts/stimulus/tom_select_controller_test.js
@@ -0,0 +1,296 @@
+/**
+ * @jest-environment jsdom
+ */
+
+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
+ * ------------------------------------------------------------------ */
+
+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({
+ ok: true,
+ 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 expectDropdownWithNoResults = () => {
+ expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe("No results found");
+};
+
+/* ------------------------------------------------------------------
+ * Specs
+ * ------------------------------------------------------------------ */
+
+describe("TomSelectController", () => {
+ let application;
+
+ beforeAll(() => {
+ application = Application.start();
+ application.register("tom-select", tom_select_controller);
+ });
+
+ beforeEach(() => {
+ global.fetch = jest.fn();
+ global.I18n = { t: (key) => key };
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ jest.clearAllMocks();
+ });
+
+ describe("connect()", () => {
+ beforeEach(() => {
+ setupDOM(`
+
+ `);
+ });
+
+ it("initializes TomSelect with default options", () => {
+ const settings = getTomSelect().settings;
+
+ expect(settings.placeholder).toBe("Default Option");
+ expect(settings.maxItems).toBe(1);
+ expect(settings.plugins).toEqual(["dropdown_input"]);
+ expect(settings.allowEmptyOption).toBe(true);
+ });
+ });
+
+ describe("connect() with custom values", () => {
+ beforeEach(() => {
+ setupDOM(`
+
+ `);
+ });
+
+ it("applies custom placeholder and options", () => {
+ const settings = getTomSelect().settings;
+
+ expect(settings.placeholder).toBe("Choose an option");
+ expect(settings.maxItems).toBe(3);
+ expect(settings.plugins).toEqual(["remove_button"]);
+ });
+ });
+
+ describe("connect() with remoteUrl", () => {
+ beforeEach(() => {
+ setupDOM(`
+
+ `);
+ });
+
+ 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 },
+ });
+
+ openDropdown();
+
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
+
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=&page=1"));
+
+ await waitFor(() => {
+ expectOptionsCount(1);
+ expectDropdownToContain("Option 1");
+ });
+ });
+
+ it("fetches remote options using search query", async () => {
+ const appleOption = { value: "apple", label: "Apple" };
+ mockRemoteFetch({
+ results: [...buildResults(1), appleOption],
+ pagination: { more: false },
+ });
+
+ openDropdown();
+
+ await waitFor(() => {
+ expectOptionsCount(2);
+ });
+
+ mockRemoteFetch({
+ results: [appleOption],
+ pagination: { more: false },
+ });
+
+ fireEvent.input(document.getElementById("select-ts-control"), {
+ target: { value: "apple" },
+ });
+
+ await waitFor(() =>
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=apple&page=1")),
+ );
+
+ await waitFor(() => {
+ expectOptionsCount(1);
+ expectDropdownToContain("Apple");
+ });
+ });
+
+ it("loads next page on scroll (infinite scroll)", async () => {
+ mockRemoteFetch(
+ {
+ results: buildResults(30),
+ pagination: { more: true },
+ },
+ {
+ results: buildResults(1, 31),
+ pagination: { more: false },
+ },
+ );
+
+ openDropdown();
+
+ await waitFor(() => {
+ expectOptionsCount(30);
+ });
+
+ const dropdown = document.querySelector(".ts-dropdown-content");
+ mockDropdownScroll(dropdown);
+
+ await waitFor(() => {
+ expectOptionsCount(31);
+ });
+
+ expect(fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("handles fetch errors gracefully", async () => {
+ fetch.mockRejectedValueOnce(new Error("Fetch error"));
+
+ openDropdown();
+
+ await waitFor(() => {
+ 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..cb4f50c0c2
--- /dev/null
+++ b/spec/requests/admin/reports_ajax_api_spec.rb
@@ -0,0 +1,328 @@
+# 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(: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],
+ 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
+
+ let(:base_params) do
+ {
+ report_type: :enterprise_fee_summary,
+ report_subtype: :enterprise_fees_with_tax_report_by_order
+ }
+ 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)
+ 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 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
+
+ expect(json_response["results"].pluck("label")).to eq(['Admin Fee', 'Delivery Fee'])
+ expect(json_response["pagination"]["more"]).to be false
+ end
+
+ 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
+
+ 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
+ end
+
+ 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
+ end
+ end
+ end
+
+ describe "GET /admin/reports/search_enterprise_fee_owners" 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: base_params
+
+ expect(response).to have_http_status(:ok)
+ json_response = response.parsed_body
+
+ 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
+ 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
+
+ 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
+
+ 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
+
+ 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
+ end
+
+ 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(7)
+ expect(json_response["pagination"]["more"]).to be false
+ end
+ end
+ end
+
+ describe "GET /admin/reports/search_order_cycles" do
+ before do
+ ocA.update!(name: "Winter Market")
+ ocB.update!(name: "Summer Market")
+ end
+
+ 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"])
+ expect(json_response["pagination"]["more"]).to be false
+ end
+ end
+
+ describe "GET /admin/reports/search_distributors" do
+ before do
+ distributor1.update!(name: "Alpha Farm")
+ distributor2.update!(name: "Beta Market")
+ end
+
+ 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
+
+ context "with more than 30 distributors" do
+ before { create_list(:distributor_enterprise, 35) }
+
+ 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"].pluck("label")).to eq(["alice@example.com"])
+ expect(json_response["pagination"]["more"]).to be false
+ end
+ end
+
+ 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"].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
+ end
+end
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)