Refactor TomSelectController to enhance remote data loading and update SearchableDropdownComponent options

This commit is contained in:
Ahmed Ejaz
2026-02-01 19:32:18 +05:00
parent b58834b11f
commit 5f31baa022
3 changed files with 226 additions and 381 deletions

View File

@@ -3,7 +3,78 @@
*/
import { Application } from "stimulus";
import tom_select_controller from "../../../app/webpacker/controllers/tom_select_controller.js";
import { fireEvent, waitFor } from "@testing-library/dom";
import tom_select_controller from "controllers/tom_select_controller";
/* ------------------------------------------------------------------
* Helpers
* ------------------------------------------------------------------ */
const buildResults = (count, start = 1) =>
Array.from({ length: count }, (_, i) => ({
value: String(start + i),
label: `Option ${start + i}`,
}));
const setupDOM = (html) => {
document.body.innerHTML = html;
};
const getSelect = () => document.getElementById("select");
const getTomSelect = () => getSelect().tomselect;
const openDropdown = () => fireEvent.click(document.getElementById("select-ts-control"));
const mockRemoteFetch = (...responses) => {
responses.forEach((response) => {
fetch.mockResolvedValueOnce({
json: async () => response,
});
});
};
const mockDropdownScroll = (
dropdown,
{ scrollHeight = 1000, clientHeight = 300, scrollTop = 700 } = {},
) => {
Object.defineProperty(dropdown, "scrollHeight", {
configurable: true,
value: scrollHeight,
});
Object.defineProperty(dropdown, "clientHeight", {
configurable: true,
value: clientHeight,
});
Object.defineProperty(dropdown, "scrollTop", {
configurable: true,
writable: true,
value: scrollTop,
});
fireEvent.scroll(dropdown);
};
/* ------------------------------------------------------------------
* Expectation helpers
* ------------------------------------------------------------------ */
const expectOptionsCount = (count) => {
expect(document.querySelectorAll('.ts-dropdown-content [role="option"]').length).toBe(count);
};
const expectDropdownToContain = (text) => {
expect(document.querySelector(".ts-dropdown-content")?.textContent).toContain(text);
};
const expectEmptyDropdown = () => {
expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe("");
};
/* ------------------------------------------------------------------
* Specs
* ------------------------------------------------------------------ */
describe("TomSelectController", () => {
let application;
@@ -14,12 +85,6 @@ describe("TomSelectController", () => {
});
beforeEach(() => {
global.requestAnimationFrame = jest.fn((cb) => {
cb();
return 1;
});
// Mock fetch for remote data tests
global.fetch = jest.fn();
});
@@ -28,291 +93,160 @@ describe("TomSelectController", () => {
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>
describe("connect()", () => {
beforeEach(() => {
setupDOM(`
<select id="select" data-controller="tom-select">
<option value="">Default Option</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>
`;
it("initializes TomSelect with default options", () => {
const settings = getTomSelect().settings;
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();
expect(settings.placeholder).toBe("Default Option");
expect(settings.maxItems).toBe(1);
expect(settings.plugins).toEqual(["dropdown_input"]);
expect(settings.allowEmptyOption).toBe(true);
});
});
describe("remote data loading (#addRemoteOptions)", () => {
it("configures remote loading with proper field and pagination setup", async () => {
document.body.innerHTML = `
<select
describe("connect() with custom values", () => {
beforeEach(() => {
setupDOM(`
<select
id="select"
data-controller="tom-select"
data-tom-select-remote-url-value="/api/search">
<option value="">Search...</option>
data-tom-select-placeholder-value="Choose an option"
data-tom-select-options-value='{"maxItems": 3, "plugins": ["remove_button"]}'
>
<option value="1">Option 1</option>
<option value="2">Option 2</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
it("applies custom placeholder and options", () => {
const settings = getTomSelect().settings;
expect(settings.placeholder).toBe("Choose an option");
expect(settings.maxItems).toBe(3);
expect(settings.plugins).toEqual(["remove_button"]);
});
});
describe("connect() with remoteUrl", () => {
beforeEach(() => {
setupDOM(`
<select
id="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");
data-tom-select-options-value='{"plugins":["virtual_scroll"]}'
data-tom-select-remote-url-value="https://ofn-tests.com/api/search"
></select>
`);
});
it("loads initial data on focus when not loading", async () => {
global.fetch.mockResolvedValue({
json: jest.fn().mockResolvedValue({
results: [],
pagination: { more: false },
}),
it("configures remote loading callbacks", () => {
const settings = getTomSelect().settings;
expect(settings.valueField).toBe("value");
expect(settings.labelField).toBe("label");
expect(settings.searchField).toBe("label");
expect(settings.load).toEqual(expect.any(Function));
expect(settings.firstUrl).toEqual(expect.any(Function));
expect(settings.onFocus).toEqual(expect.any(Function));
});
it("fetches page 1 on focus", async () => {
mockRemoteFetch({
results: buildResults(1),
pagination: { more: false },
});
document.body.innerHTML = `
<select
data-controller="tom-select"
data-tom-select-remote-url-value="/api/items">
<option value="">Search...</option>
</select>
`;
openDropdown();
await new Promise((resolve) => setTimeout(resolve, 0));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
const select = document.querySelector("select");
const controller = application.getControllerForElementAndIdentifier(select, "tom-select");
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=&page=1"));
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();
await waitFor(() => {
expectOptionsCount(1);
expectDropdownToContain("Option 1");
});
});
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>
`;
it("fetches remote options using search query", async () => {
const appleOption = { value: "apple", label: "Apple" };
mockRemoteFetch({
results: [...buildResults(1), appleOption],
pagination: { more: false },
});
await new Promise((resolve) => setTimeout(resolve, 0));
openDropdown();
const select = document.querySelector("select");
const controller = application.getControllerForElementAndIdentifier(select, "tom-select");
await waitFor(() => {
expectOptionsCount(2);
});
expect(controller).not.toBeNull();
expect(controller.scrollAttached).toBe(false);
mockRemoteFetch({
results: [appleOption],
pagination: { more: false },
});
const addEventListenerSpy = jest.spyOn(
controller.control.dropdown_content,
"addEventListener",
fireEvent.input(document.getElementById("select-ts-control"), {
target: { value: "apple" },
});
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=apple&page=1")),
);
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();
await waitFor(() => {
expectOptionsCount(1);
expectDropdownToContain("Apple");
});
});
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>
`;
it("loads next page on scroll (infinite scroll)", async () => {
mockRemoteFetch(
{
results: buildResults(30),
pagination: { more: true },
},
{
results: buildResults(1, 31),
pagination: { more: false },
},
);
await new Promise((resolve) => setTimeout(resolve, 0));
openDropdown();
const select = document.querySelector("select");
const controller = application.getControllerForElementAndIdentifier(select, "tom-select");
await waitFor(() => {
expectOptionsCount(30);
});
expect(controller).not.toBeNull();
const dropdown = document.querySelector(".ts-dropdown-content");
mockDropdownScroll(dropdown);
// Initial state
expect(controller.page).toBe(1);
expect(controller.hasMore).toBe(true);
expect(controller.loading).toBe(false);
await waitFor(() => {
expectOptionsCount(31);
});
// 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);
expect(fetch).toHaveBeenCalledTimes(2);
});
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>
`;
it("handles fetch errors gracefully", async () => {
fetch.mockRejectedValueOnce(new Error("Network error"));
await new Promise((resolve) => setTimeout(resolve, 0));
openDropdown();
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");
await waitFor(() => {
expectEmptyDropdown();
});
});
});
});