Files
openfoodnetwork/spec/javascripts/stimulus/tom_select_controller_test.js
Ahmed Ejaz 99e238d92d Improve reports AJAX search safety, permissions, and TomSelect UX
- Sanitize AJAX search queries to safely support wildcard searches (ILIKE)
- Centralize reports search actions in Spree::Ability and reuse across roles
- Fix TomSelect remote loading to fetch on dropdown open and typing
- Surface HTTP errors in TomSelect via showHttpError and improve error handling
- Update dropdown behavior to show proper “no results” feedback
- Move reports AJAX specs to request specs and expand pagination coverage
- Simplify searchable dropdown component attribute passing
2026-02-04 02:25:41 +05:00

298 lines
7.8 KiB
JavaScript

/**
* @jest-environment jsdom
*/
import { Application } from "stimulus";
import { fireEvent, waitFor } from "@testing-library/dom";
import tom_select_controller from "controllers/tom_select_controller";
import showHttpError from "js/services/show_http_error";
jest.mock("js/services/show_http_error", () => ({
__esModule: true,
default: jest.fn(),
}));
/* ------------------------------------------------------------------
* 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({
ok: true,
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 expectDropdownWithNoResults = () => {
expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe("No results found");
};
/* ------------------------------------------------------------------
* Specs
* ------------------------------------------------------------------ */
describe("TomSelectController", () => {
let application;
beforeAll(() => {
application = Application.start();
application.register("tom-select", tom_select_controller);
});
beforeEach(() => {
global.fetch = jest.fn();
global.I18n = { t: (key) => key };
});
afterEach(() => {
document.body.innerHTML = "";
jest.clearAllMocks();
});
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>
`);
});
it("initializes TomSelect with default options", () => {
const settings = getTomSelect().settings;
expect(settings.placeholder).toBe("Default Option");
expect(settings.maxItems).toBe(1);
expect(settings.plugins).toEqual(["dropdown_input"]);
expect(settings.allowEmptyOption).toBe(true);
});
});
describe("connect() with custom values", () => {
beforeEach(() => {
setupDOM(`
<select
id="select"
data-controller="tom-select"
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>
`);
});
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-options-value='{"plugins":["virtual_scroll"]}'
data-tom-select-remote-url-value="https://ofn-tests.com/api/search"
></select>
`);
});
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 },
});
openDropdown();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=&page=1"));
await waitFor(() => {
expectOptionsCount(1);
expectDropdownToContain("Option 1");
});
});
it("fetches remote options using search query", async () => {
const appleOption = { value: "apple", label: "Apple" };
mockRemoteFetch({
results: [...buildResults(1), appleOption],
pagination: { more: false },
});
openDropdown();
await waitFor(() => {
expectOptionsCount(2);
});
mockRemoteFetch({
results: [appleOption],
pagination: { more: false },
});
fireEvent.input(document.getElementById("select-ts-control"), {
target: { value: "apple" },
});
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=apple&page=1")),
);
await waitFor(() => {
expectOptionsCount(1);
expectDropdownToContain("Apple");
});
});
it("loads next page on scroll (infinite scroll)", async () => {
mockRemoteFetch(
{
results: buildResults(30),
pagination: { more: true },
},
{
results: buildResults(1, 31),
pagination: { more: false },
},
);
openDropdown();
await waitFor(() => {
expectOptionsCount(30);
});
const dropdown = document.querySelector(".ts-dropdown-content");
mockDropdownScroll(dropdown);
await waitFor(() => {
expectOptionsCount(31);
});
expect(fetch).toHaveBeenCalledTimes(2);
});
it("handles fetch errors gracefully", async () => {
fetch.mockRejectedValueOnce(new Error("Fetch error"));
openDropdown();
await waitFor(() => {
expectDropdownWithNoResults();
});
expect(showHttpError).not.toHaveBeenCalled();
});
it("displays HTTP error on failure", async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({}),
});
openDropdown();
await waitFor(() => {
expect(showHttpError).toHaveBeenCalledWith(500);
});
expectDropdownWithNoResults();
});
it("controls loading behavior based on user interaction", () => {
const settings = getTomSelect().settings;
// Initial state: openedByClick is false, query is empty
expect(settings.shouldLoad("")).toBe(false);
// Simulating opening the dropdown
settings.onDropdownOpen();
expect(settings.shouldLoad("")).toBe(true);
// Simulating typing
settings.onType();
expect(settings.shouldLoad("")).toBe(false);
// Query present
expect(settings.shouldLoad("a")).toBe(true);
});
});
});