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