Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions app/javascript/advanced_select/advanced_select_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -339,6 +339,8 @@ export default class extends Controller {
if (autoSelect) {
this.autoSelectSingle()
}

this.dispatchOptionsLoaded()
})
})
.catch(() => {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion lib/advanced_select/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module AdvancedSelect
VERSION = "0.1.10"
VERSION = "0.2.0"
end
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -339,6 +339,8 @@ export default class extends Controller {
if (autoSelect) {
this.autoSelectSingle()
}

this.dispatchOptionsLoaded()
})
})
.catch(() => {
Expand Down Expand Up @@ -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) {
Expand Down
86 changes: 86 additions & 0 deletions test/system/advanced_select_interaction_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' })")
Expand Down Expand Up @@ -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]"))
Expand Down
Loading