mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-22 00:57:26 +00:00
Refactor SearchableDropdownComponent and integrate remote data loading with TomSelect
This commit is contained in:
@@ -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