#7 Hotwire Turbo Frames: Search without page refresh. Stimulus. Ransack
Search without page refresh using Hotwire (Turbo Frames & Stimulus):
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 #
- case-insensitive search, like in this post
#app/controllers/inboxes_controller.rb
def index
if params[:name].present?
@inboxes = Inbox.where('name ilike ?', "%#{params[:name]}%")
else
@inboxes = Inbox.all
end
end
- stimulus controller to submit form with 500ms delay, inspired by this post
// 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 aturbo_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 %>
2.2. Reset search #
- 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…