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");
+ });
+ });
+});