Search without page refresh using Hotwire (Turbo Frames & Stimulus): hotwire-turbo-search

0. Initial setup #

rails g scaffold inbox name
rails db:migrate
rails c
5.times { Inbox.create(name: SecureRandom.hex) }
rails s

1. Without Ransack #

#app/controllers/inboxes_controller.rb

  def index
    if params[:name].present?
      @inboxes = Inbox.where('name ilike ?', "%#{params[:name]}%")
    else
      @inboxes = Inbox.all
    end
  end
// app/javascript/controllers/debounce_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "form" ]

  connect() { console.log("debounce controller connected") }

  search() {
  clearTimeout(this.timeout)
  this.timeout = setTimeout(() => {
      this.formTarget.requestSubmit()
    }, 500)
  }
}
  • initialize stimulus controller on form
  • stimulus target - this form
  • on input - fire the stimulus controller to submit this form
  • form target - turbo_frame: 'search' with list of inboxes
  • wrap list of inboxes into a turbo_frame_tag 'search'
#app/views/inboxes/index.html.erb
<%= form_with url: inboxes_path,
              method: :get,
              data: { controller: 'debounce',
                      debounce_target: 'form',
                      turbo_frame: 'search' } do |form| %>
  <%= form.text_field :name,
                      placeholder: 'Name',
                      value: params[:name],
                      autocomplete: 'off',
                      autofocus: true,
                      data: { action: 'input->debounce#search' } %>
<% end %>

<%= turbo_frame_tag 'search' do %>
  <%= request.url %>
  <%= link_to 'Clear search', request.path if request.query_parameters.any? %>
  <div id="inboxes">
    <%= render @inboxes %>
  </div>
<% end %>
  • Next, you will want to use target: '_top' on such a “global” turbo_frame, so that when you click a link on content inside the frame, it does not look for a turbo_frame_tag "search"
#app/views/inboxes/index.html.erb
-- <%= turbo_frame_tag 'search' do %>
++ <%= turbo_frame_tag 'search', target: '_top' do %>
-- <%= link_to 'Clear search', request.path if request.query_parameters.any? %>
++ <%= link_to 'Clear search', request.path, data: { turbo_frame: 'search'} if request.query_parameters.any? %>

2. With Ransack #

Basic search without Turbo:

#Gemfile
gem 'ransack', github: 'activerecord-hackery/ransack'
#app/controllers/inboxes_controller.rb
  def index
    @q = Inbox.ransack(params[:q])
    @inboxes = @q.result(distinct: true)
  end
#app/views/inboxes/index.html.erb
++<%= search_form_for @q do |f| %>
++  <%= f.label :name_cont %>
++  <%= f.search_field :name_cont %>
++  <%= f.submit %>
++<% end %>

++<%= sort_link @q, :messages_count, 'Popular' %>
++<%= sort_link @q, :created_at, 'Fresh' %>

++<%= link_to 'Clear search', request.path if request.query_parameters.any? %>

<div id="inboxes">
  <%= render @inboxes %>
</div>

2.1. ADD TURBO #

  • wrap the results into a frame
  • search/sort should be in the frame - so that the sort direction images get updated
  • target: "_top" - explicitly target what you need by the frame
  • the search links should have a turbo_frame ‘search’ target
  • connect the stimulus controller to the FORM
  • stimulus - submit search_field on INBPUT
#app/views/inboxes/index.html.erb
--<%= search_form_for @q, data: { turbo_frame: 'search'} do |f| %>
++<%= search_form_for @q, data: { controller: 'debounce',
++                                debounce_target: 'form',
++                                turbo_frame: 'search' } do |f| %>
    <%= f.label :name_cont %>
--  <%= f.search_field :name_cont %>
++  <%= f.search_field :name_cont,
++                     autocomplete: "off",
++                     data: { action: "input->debounce#search" } %>
    <%= f.submit %>
  <% end %>

++<%= turbo_frame_tag 'search', target: "_top" do %>
--<%= sort_link @q, :messages_count, 'Popular' %>
--<%= sort_link @q, :created_at, 'Fresh' %>
++<%= sort_link @q, :messages_count, 'Popular', {}, { data: { turbo_frame: 'search'} } %>
++<%= sort_link @q, :created_at, 'Fresh', {}, { data: { turbo_frame: 'search'} } %>

  <%= link_to 'Clear search', request.path, data: { turbo_frame: 'search'} if request.query_parameters.any? %>

  <!-- request.url - to see the URL returned by the turbo_frame -->
  <%= request.url %>

  <div id="inboxes">
    <%= render @inboxes %>
  </div>
++<% end %>
  • currently, if search_form is not in the turbo_frame, clear_search does not clear the input field
  • add a stimulus controller to “click a button -> reset an input field”

#app/javascript/controllers/reset_controller.js

import { Controller } from "@hotwired/stimulus"

//<div data-controller="reset">
//  <input data-reset-target=clearme>
//  <button data-action="click->reset#clean">clear</button>
//</div>

export default class extends Controller {
  static targets = [ "clearme" ]

  connect() { console.log("reset controller connected") }

  clean() {
    console.log(this.clearmeTarget)
    this.clearmeTarget.value=''
  }
}
  • wrap the content into the new controller: <div data-controller="reset">
  • add a target to the input field that should be reset: data: { reset_target: 'clearme',
  • add an action to the button that should reset the input: action: "click->reset#clean"
++<div data-controller="reset">
<%= search_form_for @q, data: { controller: 'debounce',
                                debounce_target: 'form',
                                turbo_frame: 'search' } do |f| %>
  <%= f.label :name_cont %>
  <%= f.search_field :name_cont,
                     autocomplete: "off",
++                     data: { reset_target: 'clearme', 
                     action: "input->debounce#search" } %>
  <%= f.submit %>
<% end %>

<%= turbo_frame_tag 'search', target: "_top" do %>
  <%= sort_link @q, :messages_count, 'Popular', {}, { data: { turbo_frame: 'search'} } %>
  <%= sort_link @q, :created_at, 'Fresh', {}, { data: { turbo_frame: 'search'} } %>

--<%= link_to 'Clear search', request.path, data: { turbo_frame: 'search'} if request.query_parameters.any? %>
++<%= link_to 'Clear search', request.path, data: { action: "click->reset#clean", turbo_frame: 'search'} if request.query_parameters.any? %>

  <!-- request.url - to see the URL returned by the turbo_frame -->
  <%= request.url %>

  <div id="inboxes">
    <%= render @inboxes %>
  </div>
<% end %>
++</div>

PERFECTO!!!

However…

A BIG DRAWBACK of this approach = this way we do not update the URL on search.

How can we do it? Turbo Drive? Some js? Who knows…