mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-21 00:47:26 +00:00
Refactor SearchableDropdownComponent and integrate remote data loading with TomSelect
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = $('<input type="hidden" />');
|
||||
$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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<input")) {
|
||||
element = document.createElement("input");
|
||||
element.type = "hidden";
|
||||
} else {
|
||||
element = selector;
|
||||
}
|
||||
|
||||
const jqObject = {
|
||||
val: mockVal,
|
||||
trigger: jest.fn().mockReturnThis(),
|
||||
select2: mockSelect2,
|
||||
hasClass: jest.fn().mockReturnValue(false),
|
||||
attr: jest.fn(function (name, value) {
|
||||
if (value !== undefined && element) {
|
||||
element.setAttribute(name, value);
|
||||
}
|
||||
return this;
|
||||
}),
|
||||
on: mockOn,
|
||||
0: element,
|
||||
_value: "",
|
||||
};
|
||||
|
||||
return jqObject;
|
||||
});
|
||||
|
||||
jQueryMock.fn = { select2: jest.fn() };
|
||||
global.$ = jQueryMock;
|
||||
|
||||
document.body.innerHTML = `
|
||||
<select
|
||||
id="test-select"
|
||||
name="test_name[]"
|
||||
data-controller="select2-ajax"
|
||||
data-select2-ajax-url-value="/api/search">
|
||||
<option value="">Select...</option>
|
||||
</select>
|
||||
`;
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
318
spec/javascripts/stimulus/tom_select_controller_test.js
Normal file
318
spec/javascripts/stimulus/tom_select_controller_test.js
Normal file
@@ -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 = `
|
||||
<select
|
||||
id="test-select"
|
||||
data-controller="tom-select"
|
||||
data-tom-select-placeholder-value="Choose an option">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="1">Option 1</option>
|
||||
<option value="2">Option 2</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select data-controller="tom-select">
|
||||
<option value="">-- Default Placeholder --</option>
|
||||
<option value="1">Option 1</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-options-value='{"maxItems": 3, "create": true}'>
|
||||
<option value="">Select</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select data-controller="tom-select">
|
||||
<option value="">Select</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-remote-url-value="/api/search">
|
||||
<option value="">Search...</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-remote-url-value="/api/search">
|
||||
<option value="">Search...</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-remote-url-value="/api/items">
|
||||
<option value="">Search...</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-remote-url-value="/api/items">
|
||||
<option value="">Search...</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-remote-url-value="/api/items">
|
||||
<option value="">Search...</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-remote-url-value="/api/items">
|
||||
<option value="">Search...</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<select
|
||||
data-controller="tom-select"
|
||||
data-tom-select-remote-url-value="/api/items">
|
||||
<option value="">Search...</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user