A very common task as a Rails developer is to let the user filter a list with a combination of search fields and selects.
Here I want to show you one way of doing it using turbo and stimulus.js from hotwire, with an example app.
The code of the app is on github.com/bhserna/dynamic filters hotwire, you can clone it and play with it.
The app displays a paginated list of products and lets the user filter the products by selecting a category and typing a search term.
By watching the file template products/index.haml, you will see that the
index page is composed of the title and two partials, filters
and table
.
%h1 Products
= render "filters"
= render "table", products: @products, pagy: @pagy
It is not necessary to break the html in this two partials but I like it that way 😅. It helps me to understand the code.
The “special” thing of the app is that it uses turbo
and stimulus.js
to
update the content when of the table when you select a new category or if you
type in the search field.
If you go to the products/filters partial you will see that the form defines three important data attributes…
data-controller="filters"
data-filters-target="form"
data-turbo-frame="table"
= form_with(url: products_path,
class: "filters-container",
method: :get,
data: { controller: "filters", filters_target: "form", turbo_frame: "table" }) do |f|
data-controller="filters"
tells stimulus.js to create a instance of the controller class in
filters_controller.js
.
data-filters-target="form"
marks the form as a formTarget
in the filters_controller.js
.
data-turbo-frame="table
tells turbo that you want to update turbo-frame
with the id table
. If
you don’t set this, turbo will change the whole page and if the user is typing
in the search input it will lose focus.
In the same form the select and the input…
.filters-group
= f.label :category, "Filter by category"
= f.select :category,
options_for_select(Product.categories.keys, params[:category]),
{ include_blank: true },
{ data: { action: "filters#submit" } }
.filters-group
= f.label :search, "Search"
= f.text_field :search,
{ value: params[:search], data: { action: "filters#submit" } }
… both have a data-action="filters#submit"
that will call the submit()
function on the filters_controller.js
.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static get targets() {
return ["form"]
}
submit(event) {
this.formTarget.requestSubmit()
}
}
To submit the form we need to actually trigger the
submit
event that’s why need to call requestSubmit()
instead of submit()
.
Now if you go to the products/table you will find that the first line wraps
the rest of the content in a turbo_frame_tag
with an id
"table"
.
= turbo_frame_tag "table" do
%div.loading-container
%div.loading-element Loading...
%div.table-container
%table
%thead
%tr
%th Name
%th Category
%th Price
%tbody
= render products
= raw pagy_nav(@pagy)
So, if you watch the html
output you will see…
<turbo-frame id="table">
# rest of the content...
</turbo-frame>
This tag matches the data-turbo-frame="table"
from the form, and now turbo
knows that it should update this “turbo frame” with the server response when
the form is submitted.
If you wathch the video again, you will see that there is a little “Loading…” that is shown when the form changes.
The html that renders this indicator is on the products/table partial, specifically this part…
= turbo_frame_tag "table" do
%div.loading-container
%div.loading-element Loading...
The css for the class .loading-element
is defined on the application.css
as…
turbo-frame .loading-element { display: none; }
turbo-frame[busy] .loading-element { display: block; }
This works because busy
is a boolean attribute managed by turbo. It is toggled to
be present when a turbo-frame
-initiated request starts, and toggled false
when the request ends
As you can see with very little “special code” hotwire can help us to achieve this very common task.
What you need to make this work is to set data-turbo-frame="table"
on a form
and have a turbo_frame_tag "table"
on the same page, and call
requestSubmit()
when you want to submit the form.
If you want a loading indicator, you can use the turbo_frame[busy]
attribute
to display the indicator when needed.
Here I try to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or all posts.