Benito Serna Tips and tools for Ruby on Rails developers

Dynamic filters with rails and hotwire

December 14, 2021

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.

Play with the code

The code of the app is on github.com/bhserna/dynamic filters hotwire, you can clone it and play with it.

What does the app do?

The app displays a paginated list of products and lets the user filter the products by selecting a category and typing a search term.

How does it work?

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.

The filters form

If you go to the products/filters partial you will see that the form defines three important data attributes…

= 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.

The inputs

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().

The table partial

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.

The loading indicator

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

Conclusion

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.

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying 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 post by topic.