A common pattern is having a search form that filters a list of items. That's what we're going to build now with a minimal amount of code using Turbo Frames and Stimulus. Here's what it will look like in the end.
We've got a bunch of jobs, each with 4 facets. A job has a commitment, like Full Time, Part Time, it has a location, a category, and whether it is remote. For each of those facets we have a search filter in the interface, a few using select boxes, and one using a checkbox. When we change the value of any of these filters, the list of jobs will be updated to only show jobs that match the selected filters.
Let's get started. You can see the entire source for the project at Turbo Frames Search Filter
Step 1: Setting Up the Rails App
Let's start by generating a scaffold for our jobs. I just used rails g scaffold Job title:string description:text commitment:integer location:string category:string remote:boolean
. The migration looks like:
class CreateJobs < ActiveRecord::Migration[7.2]
def change
create_table :jobs do |t|
t.string :title
t.text :description
t.integer :commitment, index: true
t.string :location, index: true
t.string :category, index: true
t.boolean :remote, index: true
t.timestamps
end
end
end
And with that we're setup to start building our search filters. I went to app/views/jobs/index.html.erb
and added the following to create the search form and filters.
<div class="flex justify-end mb-5 ml-8">
<%= form_with(url: jobs_path, method: :get, class: "mt-0") do %>
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-end mt-2">
<%= label_tag :category, 'Category' %>
<%= select_tag :category,
options_for_select(['Engineering', 'Marketing', 'Design', 'Sales', 'Customer Service'],
selected: params[:category]),
include_blank: true,
class: 'rounded-lg border-gray-300 mt-2' %>
</div>
<div class="flex flex-col justify-end mt-2">
<%= label_tag :location, 'Location' %>
<%= select_tag :location,
options_for_select(['New York', 'San Francisco', 'Berlin', 'Tokyo', 'London', 'Paris', 'Sydney', 'Toronto', 'Singapore', 'Remote'],
selected: params[:location]),
include_blank: true,
class: 'rounded-lg border-gray-300 mt-2' %>
</div>
<div class="flex items-center justify-start">
<%= check_box_tag :remote, '1',
params[:remote].present?, class: 'mr-2' %>
<%= label_tag :remote, 'Remote' %>
</div>
<% Job.commitments.keys.each do |commitment| %>
<div class="flex items-center justify-start">
<%= check_box_tag "commitments[]", commitment,
params[:commitments]&.include?(commitment),
id: "commitment_#{commitment}",
class: 'mr-2' %>
<%= label_tag "commitment_#{commitment}", commitment.humanize %>
</div>
<% end %>
</div>
<% end %>
</div>
With Rails we get to re-use a lot of the same actions for different purposes. In this case, we're using the index action to both show the list of jobs, and to filter the list of jobs. We'll need to update the index action in our controller to handle the filtering. Here's what I added to app/controllers/jobs_controller.rb
.
app/controllers/jobs_controller.rb
def index
@jobs = Job.all
@jobs = @jobs.where(category: params[:category]) if params[:category].present?
@jobs = @jobs.where(location: params[:location]) if params[:location].present?
@jobs = @jobs.where(remote: true) if params[:remote].present?
@jobs = @jobs.where(commitment: params[:commitments]) if params[:commitments].present?
@jobs = @jobs.order(created_at: :desc).limit(20)
end
If we submitted the form without Turbo Frames, the entire page would reload, and we'd see the filtered list of jobs. But we want to use Turbo Frames to only update the list of jobs. So let's add a Turbo Frame to the index page.
Step 2: The jobs
Turbo Frame
I added the following to app/views/jobs/index.html.erb
.
<%= turbo_frame_tag "jobs", class: "min-w-full" do %>
<%= render @jobs %>
<% end %>
What this means is that anytime the response from the server includes a turbo frame with the id of jobs
that page will only replace the contents of that frame, avoiding an entire page refresh and even the normal Turbo visit behavior of redrawing the entire page.
By the way, we could just have the form submission target the jobs
turbo frame and it would work, but we're going to use Stimulus to make it a little more interactive so that you don't need to click a submit button to update the list of jobs, but rather you can just click on any of the filters and it will update. For that we need a Stimulus controller and we need to bind it to the form and the fields.
Step 3: The filter
Stimulus Controller
What we want to do is bind a Stimulus controller to the form and the fields so that when the form is submitted, or when any of the fields change, we submit the form. Here's what I added to app/javascript/controllers/filter_controller.js
.
app/javascript/controllers/filter_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
submit(e) {
const form = this.element;
const formData = new FormData(form);
const params = new URLSearchParams(formData);
const newUrl = `${form.action}?${params.toString()}`;
Turbo.visit(newUrl, { frame: "jobs" });
history.pushState({}, "", newUrl);
}
}
Ugh, I just love how stupidly little Javascript that is. But back to explaining it.
If the form is bound to this stimulus controller, this.element
will be the form itself.
The submit
function is what we want to trigger anytime one of the form values is changed. When it fires we're going to grab the form data, serialize it into a query string, and then use Turbo to visit the new url. We're passing in the frame
option to tell Turbo to only update the jobs
frame. And then we're using history.pushState
to update the url in the browser so that if you refresh the page, you'll see the same list of jobs.
Now all that's left is binding the controller to the form and the fields so that when any of the values of the fields change, this submit
function will fire.
Step 4: Binding the filter
Stimulus Controller
We need to bind the filter
controller to the form and the fields. We can do that by adding the following to app/views/jobs/index.html.erb
.
<%= form_with(url: jobs_path, method: :get, class: "mt-0",
data: {controller: "filter"}) do %>
And then we need to bind the controller to the fields. We can do that by adding data: {action: "change->filter#submit"}
to each form filter. Now the entire form looks like:
<%= form_with(url: jobs_path, method: :get, class: "mt-0",
data: {controller: "filter"}) do %>
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-end mt-2">
<%= label_tag :category, 'Category' %>
<%= select_tag :category, options_for_select(['Engineering', 'Marketing', 'Design', 'Sales', 'Customer Service'], selected: params[:category]), include_blank: true, class: 'rounded-lg border-gray-300 mt-2',
data: {action: "change->filter#submit"} %>
</div>
<div class="flex flex-col justify-end mt-2">
<%= label_tag :location, 'Location' %>
<%= select_tag :location, options_for_select(['New York', 'San Francisco', 'Berlin', 'Tokyo', 'London', 'Paris', 'Sydney', 'Toronto', 'Singapore', 'Remote'], selected: params[:location]), include_blank: true, class: 'rounded-lg border-gray-300 mt-2',
data: {action: "change->filter#submit"} %>
</div>
<div class="flex items-center justify-start">
<%= check_box_tag :remote, '1', params[:remote].present?, class: 'mr-2',
data: {action: "change->filter#submit"} %>
<%= label_tag :remote, 'Remote' %>
</div>
<% Job.commitments.keys.each do |commitment| %>
<div class="flex items-center justify-start">
<%= check_box_tag "commitments[]", commitment, params[:commitments]&.include?(commitment), id: "commitment_#{commitment}", class: 'mr-2',
data: {action: "change->filter#submit"} %>
<%= label_tag "commitment_#{commitment}", commitment.humanize %>
</div>
<% end %>
</div>
<% end %>
And that's it! Now when you change any of the form values, the list of jobs will update without a page refresh. And if you refresh the page, you'll see the same list of jobs.
The important thing to notice is that only the turbo-frame
is being updated. Even though the index action is sending back the entire HTML for the page, the only DOM that changes is the turbo-frame
. This is the power of Turbo Frames.
PS - I tested this with half a million records on SQLite and it was stupid fast.
PPS - Having to add history.pushState({}, "", newUrl);
to advance the browser to the new URL so you could reload the page or share the URL to the state of the filters shouldn't be needed, Turbo.visit(newUrl, { frame: "jobs", action: advance });
should have worked but there's a bug that will be fixed shortly.