Dynamic Forms and Dependent Fields
Bullet Train introduces two new concepts to make your Hotwire-powered forms update dynamically on field changes.
- Dependent Fields Pattern
- Dependent Fields Frame
Dependent Fields Pattern
Let's say we have a super_select
for a "Where have you heard from us?" field. And we'll have a text_field
for "Other", disabled
by default.
<%= render 'shared/fields/super_select',
method: :heard_from,
options: {include_blank: true},
other_options: {search: true}
%>
<%= render 'shared/fields/text_field',
method: :heard_from_other,
options: {disabled: true}
%>
Our goal: if other
is selected, enable the "Other" field.
We'll wire the super_select
field with the dependable
Stimulus controller. We'll also tie both fields using the dependable-dependents-selector-value
. In this case, the id
of the the heard_from_other
field.
<%= render 'shared/fields/super_select',
method: :heard_from,
options: {include_blank: true},
other_options: {search: true},
wrapper_options: {
data: {
'controller': "dependable",
'action': '$change->dependable#updateDependents',
'dependable-dependents-selector-value': "##{form.field_id(:heard_from_other)}"
}
}
%>
<%= render 'shared/fields/text_field',
method: :heard_from_other,
id: form.field_id(:heard_from_other),
options: {disabled: true}
%>
On $change
(See super_select
dispatched events), a custom dependable:updated
event will be dispatched to all elements matching the dependable-dependents-selector-value
. This gives us flexibility: disparate form fields don't need to be wrapped with a common Stimulus controlled-wrapper. This approach is favored over Stimulus outlets
because here we're not coupling the functionality of the dependable
and dependent
fields. We're just dispatching Custom Events and using CSS selectors, preferably good old form.field_id
's.
To let our :heard_from_other
field handle the dependable:updated
event, we'll assume we have created a custom field-availability
Stimulus controller, with a #toggle
method, looking for the expected
value on the incoming event target
element, in this case the dependable
field.
<%= render 'shared/fields/text_field',
method: :heard_from_other,
id: form.field_id(:heard_from_other),
options: {
disabled: true,
data: {
controller: "field-availability",
action: "dependable:updated->field-availability#toggle",
field_availability_expected_value: "other"
}
}
%>
Note: field-availability
here is not implemented in Bullet Train. It serves as an example.
Next, we'll find a way to only serve the :heard_from_other
field to the user if "other" is selected, this time by using server-side conditionals in a turbo_frame
.
Dependent Fields Frame
What if you'd instead want to:
- Not rely on a custom Stimulus controller to control the
disabled
state of the "Other" field - Show/hide multiple dependent fields based on the value of the
dependable
field. - Update more than the field itself, but also the value of its
label
. As an example, theaddress_field
partial shows an empty "State / Province / Region" sub-field by default, and on changing the:country_id
field to the United States, changes the whole:region_id
to "State or Territory" as its label and with all US States and territories as its choices.
For these situations, Bullet Train has a dependent_fields_frame
partial that's made to listen to dependable:updated
events by default.
# update the super-select `dependable-dependents-selector-value` to "##{form.field_id(:heard_from, :dependent_fields)}" to match
<%= render "shared/fields/dependent_fields_frame",
id: form.field_id(:heard_from, :dependent_fields),
form: form,
dependable_fields: [:heard_from] do %>
<% if form.object&.heard_from == "other" %>
<%# no need for a custom `id` or the `disabled` attribute %>
<%= render 'shared/fields/text_field', method: :heard_from_other %>
<% end %>
<%# include additional fields if "other" is selected %>
<% end %>
This dependent_fields_frame
serves two purposes:
- Handle the
dependable:updated
event, so that the frame can... - Re-fetch the current form URL (it could be for a
#new
or a#edit
, it works in both situations) with a GET request (not a submit) that contains theheard_from
value as aquery_string
param. It then ensures that ourform.object.heard_from
value gets populated with the value found in thequery_string
param automatically, with no changes needed to the resource controller. That's all handled by thedependent_fields_frame
partial by reading itsdependable_fields
param.
With this functionality, the contents of the underlying turbo_frame
will be populated with the updated fields.
Now let's say we want to come back to the disabled
use case above, while using the dependent_fields_frame
approach.
We'll move the conditional on the disabled
property. And we'll also let the dependent_fields_frame
underlying controller handle disabling the field automatically when the turbo_frame
awaits updates.
<%= render "shared/fields/dependent_fields_frame",
id: form.field_id(:heard_from, :dependent_fields),
form: form,
dependable_fields: [:heard_from] do |dependent_fields_controller_name| %>
<%= render 'shared/fields/text_field',
method: :heard_from_other,
options: {
disabled: form.object&.heard_from != "other",
data: {"#{dependent_fields_controller_name}-target": "field"}
}
%>
<% end %>
To learn more about its inner functionality, search the bullet-train-core
repo for dependable_controller.js
, dependent_fields_frame_controller.js
and _dependent_fields_frame.html.erb
. You can also see an implementation by looking at the _address_field.html.erb
partial.