This repo showcases an option to build connected selects using Turbo and Stimulus. This commit contains the solution.
<form>
<select>Parent</select>
<turbo-frame>
<select>Dependent</select>
</turbo-frame>
</form>Basic HTML structure of the solution
This solution has a form containing two select tags: speed (Parent) and animals (dependent) contained within a form.
When the speed select is changed (Parent), the form submits automatically via a GET request to a specific endpoint that returns options,
the animals (Dependent) select is disabled while the request finishes and is populated with content coming from the server when the request is done.
There are 4 main components in this design:
- A Stimulus Controller to provide interactivity
- Form submitters to submit the form to a specialised endpoint
- A Turbo Frame wrapping the animals select to update it.
- Loading state using CSS
The form_controller.js is used to control the form.
<form action="/" data-controller="form">
...
</form>The <select> tag reacts to changes and submits the form via the data-action attribute.
Stimulus uses the
changeevent by default for<select>tags.
<select name="speed"
id="speed"
data-action="form#submit"
data-form-submitter-param="submit-as-get">
</select>This would read as:
on
change(implicit), execute thesubmitfunction in theformcontroller with thesubmitterparam
The Stimulus controller checks if a submitter is present and requests the form to be submitted with that submitter:
submit({ params }) {
if (params.submitter) {
this.element.requestSubmit(document.getElementById(params.submitter));
} else {
this.element.requestSubmit();
}
}The form has 2 submitters: A regular submit button and another one that leverages formaction and formmethod (docs)
to change how and where the form is submitted.
<button id="submit-as-get"
formaction="/options"
formmethod="get"
data-turbo-frame="options_select">Change options</button>The Stimulus controller would then submit the form:
- to
/options - with the
GETmethod - pointing to the
options_selectTurbo Frame
Caution
All the state from the form will be submitted, so sensitive information could end up in logs and be visible in non-secure connections. Consider filtering sensitive information. This is available in Rails via Parameter Filtering
When the request is submitted, Turbo identifies it as a Turbo Frame request which scopes changes to the DOM only to the Frame referenced.
In this example, the animals <select> tag is wrapped with a <turbo-frame> element so that any Turbo DOM changes performed
by Turbo, only affect that frame.
<turbo-frame id="options_select">
<div class="loading">
<select name="loading" id="loading" disabled>
<option value="loading">Loading...</option>
</select>
</div>
<div class="content">
<select name="animal" id="animal" data-action="turbo:frame">
<% options.each do |animal| %>
<option value="<%= animal %>"><%= animal %></option>
<% end %>
</select>
</div>
</turbo-frame>Turbo adds attributes to the DOM when requests are in flight
so CSS can be used to display a loading state while the response comes back from the server. The aria-busy="true" attribute
is added to the <turbo-frame> while the request is in progress and is removed when the response is returned.
The markup then requires two wrapping elements to be present. In this example:
- a wrapper (identified with the
.loadingclass) wrapping a loading state select. - a wrapper (identified with the
.contentclass) for the real select where dynamic content will be slotted in.
By targeing elements using CSS, the real select element is hidden and a loading state is presented:
turbo-frame {
& .loading { display: none;}
& .content { display: block;}
&[aria-busy="true"]{
& .loading { display: block; }
& .content { display: none; }
}
}Note
This example is quite generic, so more careful selectors need to be used in a real scenario.
The Rails controller handling the dynamic options delays the request based on the parameter sent from the speed (Parent) select
to simulate race conditions.
def options
@options = animals[params[:speed].to_sym] || animals[:slow]
case params[:speed].to_sym
when :slow then sleep 10
when :medium then sleep 6
when :fast then sleep 1
end
endTurbo includes mechanisms to deal with race conditions by default. When multiple requests are performed to drive a single Frame, Turbo will cancel past requests and keep only the latest one.
If you look closely, the "fast" request does not seems that fast when issuing mulitple requests in rapid succession. This
is because I have a single server running and it has to wait until the calls to sleep finish for al requests. In a robust production
setup you'd have a load balancer and multiple servers available to process requests so you wouldn't see this issue if the query to
retrieve the options took long.
