From c1e4d6af43d5778f7dd72dcfffc366150428dcfb Mon Sep 17 00:00:00 2001 From: "ulsyemr@gmail.com" Date: Mon, 17 Aug 2026 16:24:28 +0300 Subject: [PATCH] Add options response seams and bump version to 0.2.0 --- Gemfile.lock | 2 +- README.md | 40 +++++++++ .../advanced_select_controller.js | 27 +++++- lib/advanced_select/version.rb | 2 +- .../templates/advanced_select_controller.js | 27 +++++- .../advanced_select_interaction_test.rb | 86 +++++++++++++++++++ 6 files changed, 176 insertions(+), 8 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index eae2dde..ab6218a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - advanced_select (0.1.10) + advanced_select (0.2.0) actionview (>= 7.1) railties (>= 7.1) stimulus-rails (>= 1.3) diff --git a/README.md b/README.md index 628e98f..e1e052a 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,30 @@ application.register("advanced-select", AdvancedSelectController) This keeps local custom behavior small while allowing future gem fixes to flow through the base controller. +Two methods exist as extension points for host apps whose options endpoint does not speak Turbo +Streams. `readOptionsResponse(response)` turns the `fetch` response into a payload and defaults to +`response.text()`; `renderOptionsResponse(payload)` puts that payload on the page and defaults to +`Turbo.renderStreamMessage(payload)`. Overriding them keeps the request sequencing, the stale +response guard, and the post-render bookkeeping in the base controller: + +```js +export default class extends AdvancedSelectController { + readOptionsResponse(response) { + return response.headers.get("content-type")?.includes("application/json") + ? response.json() + : super.readOptionsResponse(response) + } + + renderOptionsResponse(payload) { + if (Array.isArray(payload)) { + this.replaceOptions(payload) + } else { + super.renderOptionsResponse(payload) + } + } +} +``` + For `jsbundling-rails` and other bundlers, the installer copies the full controller because bundlers do not resolve Rails engine JavaScript assets automatically. In that setup the copied file is host-owned. ### jsbundling/Propshaft Example @@ -758,6 +782,22 @@ Every value change dispatches two events: | `change` | the first hidden input | native form behaviour and [dependent fields](#dependent-fields) | | `advanced-select:change` | the root element | application listeners that need the whole selection | +A remote field also announces when its options finish rendering: + +| Event | Target | Purpose | +| --- | --- | --- | +| `advanced-select:options-loaded` | the root element | application listeners that react to the option list itself | + +It fires after every successful [remote](#remote-search) load — including eager +[dependent](#dependent-fields) loads — once the options are in the DOM and any single-option +auto-selection has been applied. Its `detail` carries the field `name`, the resulting `value`, and +the `count` of selectable options, which is enough to react to an empty or single-option result +without reading the DOM: + +```erb +data-action="advanced-select:options-loaded->my-controller#optionsChanged" +``` + Both bubble. `advanced-select:change` carries a `detail` payload: ```js diff --git a/app/javascript/advanced_select/advanced_select_controller.js b/app/javascript/advanced_select/advanced_select_controller.js index c8cd901..4933360 100644 --- a/app/javascript/advanced_select/advanced_select_controller.js +++ b/app/javascript/advanced_select/advanced_select_controller.js @@ -319,14 +319,14 @@ export default class extends Controller { throw new Error("Advanced select options request failed") } - return response.text() + return this.readOptionsResponse(response) }) - .then((html) => { + .then((payload) => { if (!(eager || this.expanded) || requestSequence !== this.requestSequence) { return } - Turbo.renderStreamMessage(html) + this.renderOptionsResponse(payload) requestAnimationFrame(() => { if (!(eager || this.expanded) || requestSequence !== this.requestSequence) { return @@ -339,6 +339,8 @@ export default class extends Controller { if (autoSelect) { this.autoSelectSingle() } + + this.dispatchOptionsLoaded() }) }) .catch(() => { @@ -495,6 +497,25 @@ export default class extends Controller { } } + readOptionsResponse(response) { + return response.text() + } + + renderOptionsResponse(payload) { + Turbo.renderStreamMessage(payload) + } + + dispatchOptionsLoaded() { + this.element.dispatchEvent(new CustomEvent("advanced-select:options-loaded", { + bubbles: true, + detail: { + name: this.nameValue, + value: this.currentValue, + count: this.selectableOptionElements.length + } + })) + } + dispatchValueChange() { const input = this.hiddenFieldsTarget.querySelector("input") if (input) { diff --git a/lib/advanced_select/version.rb b/lib/advanced_select/version.rb index 7470a2d..cc617e6 100644 --- a/lib/advanced_select/version.rb +++ b/lib/advanced_select/version.rb @@ -1,3 +1,3 @@ module AdvancedSelect - VERSION = "0.1.10" + VERSION = "0.2.0" end diff --git a/lib/generators/advanced_select/install/templates/advanced_select_controller.js b/lib/generators/advanced_select/install/templates/advanced_select_controller.js index c8cd901..4933360 100644 --- a/lib/generators/advanced_select/install/templates/advanced_select_controller.js +++ b/lib/generators/advanced_select/install/templates/advanced_select_controller.js @@ -319,14 +319,14 @@ export default class extends Controller { throw new Error("Advanced select options request failed") } - return response.text() + return this.readOptionsResponse(response) }) - .then((html) => { + .then((payload) => { if (!(eager || this.expanded) || requestSequence !== this.requestSequence) { return } - Turbo.renderStreamMessage(html) + this.renderOptionsResponse(payload) requestAnimationFrame(() => { if (!(eager || this.expanded) || requestSequence !== this.requestSequence) { return @@ -339,6 +339,8 @@ export default class extends Controller { if (autoSelect) { this.autoSelectSingle() } + + this.dispatchOptionsLoaded() }) }) .catch(() => { @@ -495,6 +497,25 @@ export default class extends Controller { } } + readOptionsResponse(response) { + return response.text() + } + + renderOptionsResponse(payload) { + Turbo.renderStreamMessage(payload) + } + + dispatchOptionsLoaded() { + this.element.dispatchEvent(new CustomEvent("advanced-select:options-loaded", { + bubbles: true, + detail: { + name: this.nameValue, + value: this.currentValue, + count: this.selectableOptionElements.length + } + })) + } + dispatchValueChange() { const input = this.hiddenFieldsTarget.querySelector("input") if (input) { diff --git a/test/system/advanced_select_interaction_test.rb b/test/system/advanced_select_interaction_test.rb index 36632bf..b39f50c 100644 --- a/test/system/advanced_select_interaction_test.rb +++ b/test/system/advanced_select_interaction_test.rb @@ -440,6 +440,70 @@ class AdvancedSelectInteractionTest < ApplicationSystemTestCase assert_equal "local-9", select_call("example_item_id", "getValue()") end + test "broadcasts advanced-select:options-loaded once remote options are rendered" do + visit root_path + record_options_loaded_events + + find("#example_remote_id_trigger").click + + assert_selector "#example_remote_id_options button", text: "Remote Alpha" + + events = options_loaded_events.select { |event| event["name"] == "example[remote_id]" } + + assert_equal 1, events.size + assert_equal 4, events.first["count"] + assert_equal "", events.first["value"] + end + + test "broadcasts advanced-select:options-loaded for eagerly loaded dependent options" do + visit root_path + + assert_selector "#example_eager_dependent_id_summary", text: "Dependent North" + + record_options_loaded_events + select "South", from: "example_eager_dependency" + + assert_selector "#example_eager_dependent_id_summary", text: "Dependent South" + + events = options_loaded_events.select { |event| event["name"] == "example[eager_dependent_id]" } + + assert_equal "dependent-south", events.last["value"] + end + + test "reads the options response through readOptionsResponse" do + visit root_path + page.execute_script(<<~JS) + ((select) => { + const original = select.readOptionsResponse.bind(select) + window.__readCalls = 0 + select.readOptionsResponse = (response) => { + window.__readCalls += 1 + return original(response) + } + })(#{controller_script('example_remote_id')}) + JS + + find("#example_remote_id_trigger").click + + assert_selector "#example_remote_id_options button", text: "Remote Alpha" + assert_equal 1, page.evaluate_script("window.__readCalls") + end + + test "renders the options response through renderOptionsResponse" do + visit root_path + page.execute_script(<<~JS) + ((select) => { + select.renderOptionsResponse = (payload) => { window.__payload = payload } + })(#{controller_script('example_remote_id')}) + JS + + find("#example_remote_id_trigger").click + + assert_selector "#example_remote_id_options", text: "No options found" + assert_no_selector "#example_remote_id_options button", text: "Remote Alpha" + assert_includes page.evaluate_script("window.__payload"), "Remote Alpha" + end + test "adds an option to the end of the list through appendOption" do visit root_path select_call("example_item_id", "appendOption({ id: 'local-9', label: 'Local Nine' })") @@ -736,6 +800,28 @@ def advanced_select_events page.evaluate_script("window.__advancedSelectEvents") end + def controller_script(select_id) + <<~JS.strip + window.Stimulus.getControllerForElementAndIdentifier( + document.getElementById("#{select_id}_trigger").closest("[data-controller~='advanced-select']"), + "advanced-select" + ) + JS + end + + def record_options_loaded_events + page.execute_script(<<~JS) + window.__advancedSelectOptionsLoaded = [] + document.addEventListener("advanced-select:options-loaded", (event) => { + window.__advancedSelectOptionsLoaded.push(event.detail) + }) + JS + end + + def options_loaded_events + page.evaluate_script("window.__advancedSelectOptionsLoaded") + end + def option_values(select_id) page.evaluate_script(<<~JS) Array.from(document.querySelectorAll("##{select_id}_options [data-advanced-select-option]"))