From 5f31baa022703fa5052438094ee012e319a1f28f Mon Sep 17 00:00:00 2001 From: Ahmed Ejaz Date: Sun, 1 Feb 2026 19:32:18 +0500 Subject: [PATCH] Refactor TomSelectController to enhance remote data loading and update SearchableDropdownComponent options --- .../searchable_dropdown_component.rb | 5 +- .../controllers/tom_select_controller.js | 164 ++----- .../stimulus/tom_select_controller_test.js | 438 ++++++++---------- 3 files changed, 226 insertions(+), 381 deletions(-) diff --git a/app/components/searchable_dropdown_component.rb b/app/components/searchable_dropdown_component.rb index 821bf89979..c6a6852edf 100644 --- a/app/components/searchable_dropdown_component.rb +++ b/app/components/searchable_dropdown_component.rb @@ -46,8 +46,9 @@ class SearchableDropdownComponent < ViewComponent::Base end def tom_select_options_value - plugins = remove_search_plugin? ? [] : ['dropdown_input'] - multiple ? plugins << 'remove_button' : plugins + plugins = ['virtual_scroll'] + plugins << 'dropdown_input' unless remove_search_plugin? + plugins << 'remove_button' if multiple { plugins:, diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js index 81651f038b..e64bb60939 100644 --- a/app/webpacker/controllers/tom_select_controller.js +++ b/app/webpacker/controllers/tom_select_controller.js @@ -40,143 +40,53 @@ export default class extends Controller { return optionsArray.find((option) => [null, ""].includes(option.value))?.text; } + #buildUrl(query, page = 1) { + const url = new URL(this.remoteUrlValue, window.location.origin); + url.searchParams.set("q", query); + url.searchParams.set("page", page); + return url.toString(); + } + + #fetchOptions(query, callback) { + const url = this.control.getUrl(query); + + fetch(url) + .then((response) => response.json()) + .then((json) => { + /** + * Expected API shape: + * { + * results: [{ value, label }], + * pagination: { more: boolean } + * } + */ + if (json.pagination?.more) { + const currentUrl = new URL(url); + const currentPage = parseInt(currentUrl.searchParams.get("page") || "1"); + const nextUrl = this.#buildUrl(query, currentPage + 1); + this.control.setNextUrl(query, nextUrl); + } + + callback(json.results || []); + }) + .catch(() => { + callback(); + }); + } + #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; + options.firstUrl = (query) => { + return this.#buildUrl(query, 1); }; - /** - * 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.load = this.#fetchOptions.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/spec/javascripts/stimulus/tom_select_controller_test.js b/spec/javascripts/stimulus/tom_select_controller_test.js index f53bae3ff7..cba45dfede 100644 --- a/spec/javascripts/stimulus/tom_select_controller_test.js +++ b/spec/javascripts/stimulus/tom_select_controller_test.js @@ -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 = ` - + - `; - - 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 = ` - - `; + 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 = ` - - `; - - 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(); + 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 = ` - - + data-tom-select-placeholder-value="Choose an option" + data-tom-select-options-value='{"maxItems": 3, "plugins": ["remove_button"]}' + > + + - `; - - 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"); + data-tom-select-options-value='{"plugins":["virtual_scroll"]}' + data-tom-select-remote-url-value="https://ofn-tests.com/api/search" + > + `); }); - 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 = ` - - `; + 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 = ` - - `; + 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 = ` - - `; - - 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 = ` - - `; + 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 = ` - - `; + 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(); + }); }); }); });