Live Search
Search, filter, sort and page through a general store's shelves. The list and the count follow each change without being told to. The address bar also follows, so a search survives a reload and can be shared.
General Store
64 items
- Amulet of Kindling Lv 20 800 gp
- Antidote Lv 5 60 gp
- Black Iron Axe Lv 25 880 gp
- Bowl of Stew Lv 1 22 gp
- Bronze Dagger Lv 1 20 gp
- Bronze Pickaxe Lv 1 25 gp
- Cabbage Lv 1 1 gp
- Chain Hauberk Lv 25 1,100 gp
# frozen_string_literal: true
# Live search over the general store. The filters are five states; the count,
# the page of rows and the pager are derived from them. Actions only write
# state, and never say which part of the page should change.
class Recipes::Search::SearchChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Item = Recipes::Search::Item
Filters = Recipes::Search::Filters
VIEWS = "app/recipes/search"
def build_graph
# The tab subscribes with the filters its URL named, so a reloaded or
# shared link starts where it left off.
start = Filters.from(params)
@q = Hibiki::State.new(start.q)
@kind = Hibiki::State.new(start.kind)
@max_level = Hibiki::State.new(start.max_level)
@sort = Hibiki::State.new(start.sort)
@page = Hibiki::State.new(start.page)
@cleared = Hibiki::State.new(0)
count = Hibiki::Derived.new { matching.count }
pages = Hibiki::Derived.new { Item.pages(count.value) }
page = Hibiki::Derived.new { @page.value.clamp(1, pages.value) }
rows = Hibiki::Derived.new(equals: Hibiki::Rails.record_equals) do
matching.sorted(@sort.value).on_page(page.value).to_a
end
Hibiki::Effect.new do
broadcast_morph target: "search_results", partial: "#{VIEWS}/results", locals: { items: rows.value }
end
Hibiki::Effect.new do
broadcast_replace target: "search_pager", partial: "#{VIEWS}/pager",
locals: { page: page.value, pages: pages.value }
end
Hibiki::Effect.new do
broadcast_replace target: "search_status", partial: "#{VIEWS}/status",
locals: { q: @q.value, kind: @kind.value, max_level: @max_level.value }
end
# The controls are the visitor's to change, so the channel leaves them
# alone, except to empty them after Clear.
Hibiki::Effect.new do
next unless @cleared.value.positive?
broadcast_replace target: "search_form", partial: "#{VIEWS}/controls", locals: { filters: Filters.new }
end
transmit_value(:search_count) { "#{count.value} #{"item".pluralize(count.value)}" }
transmit_value(:search_max_level) { @max_level.value }
transmit_url do
filters = Filters.new(q: @q.value, kind: @kind.value, max_level: @max_level.value,
sort: @sort.value, page: page.value)
urls.app_recipe_path("search", **filters.to_query)
end
end
def search(data)
@q.value = data["q"].to_s.first(60)
@page.value = 1
end
def set_kind(data)
return unless data["kind"] == "all" || Item::KINDS.include?(data["kind"])
@kind.value = data["kind"]
@page.value = 1
end
def set_max_level(data)
@max_level.value = data["max_level"].to_i.clamp(1, Item::MAX_LEVEL)
@page.value = 1
end
def set_sort(data)
@sort.value = data["sort"] if Item::SORTS.key?(data["sort"])
end
def go(data) = @page.value = data["page"].to_i
def clear
defaults = Filters.new
@q.value = defaults.q
@kind.value = defaults.kind
@max_level.value = defaults.max_level
@sort.value = defaults.sort
@page.value = defaults.page
@cleared.value += 1
end
private
# The filtered shelves. Whatever calls this reads the three filter states,
# so it runs again when one of them changes.
def matching = Item.matching(q: @q.value, kind: @kind.value, max_level: @max_level.value)
def urls = Rails.application.routes.url_helpers
end
# frozen_string_literal: true
# Something on the shelves of the general store in the Live Search recipe.
class Recipes::Search::Item < ApplicationRecord
self.table_name = "recipes_search_items"
KINDS = %w[weapon armour food potion tool trinket].freeze
MAX_LEVEL = 60
PER_PAGE = 8
# What the sort dropdown offers, and the only orders a visitor can ask for.
SORTS = {
"name" => { name: :asc },
"cheapest" => { price: :asc, name: :asc },
"priciest" => { price: :desc, name: :asc },
"level" => { level: :asc, name: :asc }
}.freeze
SEED_FILE = Rails.root.join("db/seeds/search_items.yml")
# Every filter at its default lets everything through.
scope :matching, ->(q: "", kind: "all", max_level: MAX_LEVEL) {
scope = all
scope = scope.where("name ILIKE ?", "%#{sanitize_sql_like(q.strip)}%") if q.present?
scope = scope.where(kind:) unless kind == "all"
scope = scope.where(level: ..max_level) if max_level < MAX_LEVEL
scope
}
scope :sorted, ->(key = "name") { order(SORTS.fetch(key, SORTS["name"])) }
scope :on_page, ->(number) { offset((number - 1) * PER_PAGE).limit(PER_PAGE) }
# How many pages a number of items fills. Never less than one.
def self.pages(count) = [ count.fdiv(PER_PAGE).ceil, 1 ].max
# Loads the shelves from db/seeds/search_items.yml. Safe to run again.
def self.seed!
upsert_all(YAML.load_file(SEED_FILE), unique_by: :name)
end
end
# frozen_string_literal: true
# What a visitor is searching for, as the address bar carries it. `from`
# reads a query string (or the params a tab subscribes with) and trusts
# none of it; `to_query` writes one back, leaving out whatever is at its
# default so a plain search has a plain URL.
class Recipes::Search::Filters < Data.define(:q, :kind, :max_level, :sort, :page)
Item = Recipes::Search::Item
DEFAULTS = { q: "", kind: "all", max_level: Item::MAX_LEVEL, sort: "name", page: 1 }.freeze
def self.from(params)
kind, sort = params[:kind].to_s, params[:sort].to_s
new(
q: params[:q].to_s.first(60),
kind: Item::KINDS.include?(kind) ? kind : DEFAULTS[:kind],
max_level: params[:max_level].present? ? params[:max_level].to_i.clamp(1, Item::MAX_LEVEL) : DEFAULTS[:max_level],
sort: Item::SORTS.key?(sort) ? sort : DEFAULTS[:sort],
page: [ params[:page].to_i, 1 ].max
)
end
def initialize(**given) = super(**DEFAULTS, **given)
# The part that decides which items match.
def matching = { q:, kind:, max_level: }
def to_query = to_h.reject { |name, value| value == DEFAULTS[name] }
end
<%# The page starts from the filters in its URL, and hands the same ones to
the channel as it subscribes. %>
<% filters = Recipes::Search::Filters.from(params) %>
<% shelves = Recipes::Search::Item.matching(**filters.matching) %>
<% pages = Recipes::Search::Item.pages(shelves.count) %>
<% page = filters.page.clamp(1, pages) %>
<%= island Recipes::Search::SearchChannel, params: filters.to_query,
class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">General Store</h2>
<p class="text-sm opacity-70"><%= reactive :search_count, pluralize(shelves.count, "item") %></p>
</header>
<%= render "app/recipes/search/controls", filters: %>
<%= render "app/recipes/search/status", **filters.matching %>
<%= render "app/recipes/search/results", items: shelves.sorted(filters.sort).on_page(page) %>
<%= render "app/recipes/search/pager", page:, pages: %>
<% end %>
<%# locals: (filters:) -%>
<%# Rendered with the page, and again only after Clear, so typing is never
interrupted. Enter submits the form, which searches straight away. %>
<%= tag.form id: "search_form", class: "mt-3 grid gap-3 sm:grid-cols-2",
**on(:search, event: :submit, reset: false) do %>
<label class="input w-full sm:col-span-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="size-4 opacity-60"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<%= tag.input type: "search", name: "q", value: filters.q, placeholder: "Search the shelves",
autocomplete: "off", "aria-label": "Search items", class: "grow",
**on(:search, event: :input) %>
<%# Shown by CSS alone while a round trip is in flight. %>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</label>
<%= tag.select name: "kind", "aria-label": "Kind", class: "select w-full",
**on(:set_kind, event: :change) do %>
<%= options_for_select [ [ "All kinds", "all" ], *Recipes::Search::Item::KINDS.map { [ it.capitalize, it ] } ],
filters.kind %>
<% end %>
<%= tag.select name: "sort", "aria-label": "Sort by", class: "select w-full",
**on(:set_sort, event: :change) do %>
<%= options_for_select [ [ "Name", "name" ], [ "Cheapest first", "cheapest" ],
[ "Priciest first", "priciest" ], [ "Lowest level first", "level" ] ],
filters.sort %>
<% end %>
<label class="flex items-center gap-3 text-sm sm:col-span-2">
<span class="whitespace-nowrap">Usable at level <b class="inline-block w-5 tabular-nums"><%= reactive :search_max_level, filters.max_level %></b></span>
<%= tag.input type: "range", name: "max_level", min: 1, max: Recipes::Search::Item::MAX_LEVEL,
value: filters.max_level, class: "range range-primary range-sm grow",
**on(:set_max_level, event: :input, debounce: 100) %>
</label>
<% end %>
<%# locals: (items:) -%>
<%# Morphed by the channel. Each row keeps its id, so a row that survives a
search stays in the page and only the new ones fade in. %>
<div id="search_results" class="mt-1 min-h-84">
<ul id="search_items" class="divide-y-2 divide-base-300">
<% items.each do |item| %>
<li id="search_item_<%= item.id %>"
class="flex items-center gap-3 py-2 transition-opacity duration-300 starting:opacity-0 motion-reduce:transition-none">
<span class="min-w-0 flex-1 truncate font-semibold"><%= item.name %></span>
<span class="badge badge-sm badge-neutral hidden sm:inline-flex"><%= item.kind %></span>
<span class="w-12 text-right text-sm tabular-nums opacity-70">Lv <%= item.level %></span>
<span class="w-24 text-right tabular-nums"><%= number_with_delimiter item.price %> <span class="text-sm opacity-70">gp</span></span>
</li>
<% end %>
</ul>
<% if items.empty? %>
<div id="search_empty" class="py-14 text-center transition-opacity duration-300 starting:opacity-0">
<p class="font-bold">Nothing on the shelves matches that.</p>
<p class="text-sm opacity-70">The shopkeeper suggests a cabbage instead.</p>
</div>
<% end %>
</div>
<%# locals: (page:, pages:) -%>
<nav id="search_pager" class="mt-3 flex justify-center" aria-label="Pages">
<div class="join">
<%= tag.button "«", type: "button", class: "btn btn-sm join-item", disabled: page <= 1,
"aria-label": "Previous page", **on(:go, with: { page: page - 1 }) %>
<% 1.upto(pages) do |number| %>
<%= tag.button number, type: "button", class: [ "btn btn-sm join-item hidden sm:inline-flex", "btn-primary": number == page ],
"aria-current": (number == page ? "page" : nil), **on(:go, with: { page: number }) %>
<% end %>
<%# Narrow screens have no room for a button per page. %>
<span class="btn btn-sm join-item pointer-events-none sm:hidden">Page <%= page %> of <%= pages %></span>
<%= tag.button "»", type: "button", class: "btn btn-sm join-item", disabled: page >= pages,
"aria-label": "Next page", **on(:go, with: { page: page + 1 }) %>
</div>
</nav>
<%# locals: (q:, kind:, max_level:) -%>
<% filters = [
(%(“#{q.strip}”) if q.present?),
(kind.capitalize unless kind == "all"),
("Level #{max_level} or lower" if max_level < Recipes::Search::Item::MAX_LEVEL)
].compact %>
<div id="search_status" class="mt-3 flex min-h-8 flex-wrap items-center gap-2 border-b-2 border-base-300 pb-2 text-sm">
<% if filters.empty? %>
<span class="opacity-70">Everything on the shelves.</span>
<% else %>
<% filters.each do |filter| %>
<span class="badge badge-accent"><%= filter %></span>
<% end %>
<%= tag.button "Clear filters", type: "button", class: "btn btn-ghost btn-xs ml-auto", **on(:clear) %>
<% end %>
</div>
The form above shows a live search: one that can be very tedious to achieve with stock Rails. The address bar follows too, so the search survives a page reload and can be shared between users. Each part of the search reads the filters it shows and is rendered again when one of them changes. We didn't have to map everything out by hand.
How it fits together
The filters live in a channel: the server side of an Action
Cable connection, an object that stays alive while the page is open and
answers messages from it. A state holds a value and remembers
who read it. Here each filter is a state, @q,
@kind and so on, and the page number is one more. The
channel's public methods are its actions, the methods the page
may call. Each one writes a filter or two and returns.
A derived is a value worked out from other values, and worked
out again when one of them changes. The query is one private method,
matching, which reads the three states that decide what is
on the shelves. Here count and rows are
deriveds that call it, and pages and page
follow from the count.
An effect is a block of code that runs once, and runs again
whenever a value it read has changed. The channel has one for the list,
one for the pager and one for the status line. Each reads what it shows,
renders its partial and sends it to the page. The count in the heading
and the number beside the slider go as text, with
transmit_value. This is what happens when you type
bronze:
-
The search box sends
searchwith what was typed, once you pause for 250 ms. -
searchwrites@qand sets@pageback to 1. -
countandrowsread@qthroughmatching, so they are worked out again, andpagesandpageafter them. -
Each effect read one of those, or
@qitself, so each renders its part again and sends it. The count in the heading goes with them.
Now change the sort order. Only rows read
@sort, so the list is the one part sent. A search that
matches the same eight rows as before sends no list at all.
The part of a page that talks to a channel is an island: one
ordinary HTML element with the channel's controls and output inside it.
Here _search.html.erb is the island, and each control sends
its action with on, such as
on(:search, event: :input) on the search box. The controls
sit outside every fragment the channel renders, so a reply never
replaces the box you are typing in. The one time the channel does render
them is after Clear filters: clear adds one to a
counter, and an effect that read the counter sends the form back with
every field at its default.
The address bar keeps up
A search that lives in a socket is gone when the page reloads, unless the page writes it down somewhere. Here it goes in the URL, so a reload or a shared link lands on the same search. With Hibiki, you can do this with a single block in the channel.
# in build_graph
transmit_url do
filters = Filters.new(q: @q.value, kind: @kind.value, max_level: @max_level.value,
sort: @sort.value, page: page.value)
urls.app_recipe_path("search", **filters.to_query)
end
transmit_url is one more reader of the filter states. When
the path it works out changes, it sends the path, and the client puts it
in the address bar with history.replaceState, so Back still
leaves the page. Filters is a small value object holding
what a visitor is searching for. Its to_query leaves out
anything at its default, which keeps a plain search at a plain URL.
The other direction is ordinary Rails. The page reads its query string
into a Filters, renders the shelves from it, and hands it to
island as params:, so the channel starts from
the same values. Filters.from checks each one, since it
came from the browser.
Adding one more filter
The level slider was added last. It took a state, an action that writes
it, one more argument to the query, and the input (and one more field in
Filters, to carry it in the URL):
# in build_graph
@max_level = Hibiki::State.new(start.max_level)
def set_max_level(data)
@max_level.value = data["max_level"].to_i.clamp(1, Item::MAX_LEVEL)
@page.value = 1
end
def matching = Item.matching(q: @q.value, kind: @kind.value, max_level: @max_level.value)
No effect was edited. The count, the list and the pager already read
matching, so they followed the new filter without being told
about it.
The same feature in stock Rails
Rails can build this page with Turbo Streams and Stimulus, and many apps do. Here is a sketch of what that takes. It is not run here, and it leaves out the partials, which would be the same in both versions.
# A sketch of the stock Rails version. It is not part of this app.
class ItemsController < ApplicationController
def index
# Nothing is remembered between requests, so every form and every pager
# link has to send all of this back, and a new filter joins this list.
@filters = params.permit(:q, :kind, :max_level, :sort, :page).to_h
scope = Item.matching(
q: @filters.fetch("q", ""),
kind: @filters.fetch("kind", "all"),
max_level: @filters.fetch("max_level", Item::MAX_LEVEL).to_i
)
# One instance variable for each part of the page that shows a result.
@count = scope.count
@pages = Item.pages(@count)
@page = @filters.fetch("page", 1).to_i.clamp(1, @pages)
@items = scope.sorted(@filters.fetch("sort", "name")).on_page(@page)
respond_to do |format|
format.html # first load, reload, shared link
format.turbo_stream # every search after that
end
end
end
<%# Every part a search can change needs an id, and the stream template
has to name each one. %>
<h2>General Store <span id="search_count"><%= pluralize(@count, "item") %></span></h2>
<%# The form submits itself through a Stimulus controller. Each field
carries its own data-action, and the slider's label is a target the
controller writes to. %>
<%= form_with url: items_path, method: :get,
data: { controller: "search", turbo_stream: true } do |form| %>
<%= form.search_field :q, value: @filters["q"],
data: { action: "input->search#changed" } %>
<%= form.select :kind, kind_options, { selected: @filters["kind"] },
data: { action: "change->search#changed" } %>
<%= form.select :sort, sort_options, { selected: @filters["sort"] },
data: { action: "change->search#changed" } %>
Usable at level <b data-search-target="levelLabel"><%= @filters.fetch("max_level", Item::MAX_LEVEL) %></b>
<%= form.range_field :max_level, in: 1..Item::MAX_LEVEL, value: @filters.fetch("max_level", Item::MAX_LEVEL),
data: { search_target: "level", action: "input->search#changed" } %>
<% end %>
<%= render "items/status", filters: @filters %>
<%= render "items/results", items: @items %>
<%= render "items/pager", page: @page, pages: @pages, filters: @filters %>
<%# The list of everything a search can change, kept by hand. Forget a
line and that part of the page goes stale without an error. A new part
is a new line here, a new id in the page, and a new instance variable
in the controller. %>
<%= turbo_stream.replace "search_results", partial: "items/results", locals: { items: @items } %>
<%= turbo_stream.replace "search_pager", partial: "items/pager", locals: { page: @page, pages: @pages, filters: @filters } %>
<%= turbo_stream.replace "search_status", partial: "items/status", locals: { filters: @filters } %>
<%= turbo_stream.update "search_count", pluralize(@count, "item") %>
<%# locals: (page:, pages:, filters:) -%>
<nav id="search_pager">
<% 1.upto(pages) do |number| %>
<%# Each link carries every filter, or page 2 forgets the search. It
also asks for a stream, so it needs the same address-bar fix as
the form: one more Stimulus action. %>
<%= link_to number, items_path(filters.merge(page: number)),
data: { turbo_stream: true, action: "search#visited" },
aria: { current: (number == page ? "page" : nil) } %>
<% end %>
</nav>
import { Controller } from "@hotwired/stimulus"
// The parts of a live search that Turbo does not do for you.
export default class extends Controller {
static targets = ["level", "levelLabel"]
// Wired by hand to every field in the form.
changed() {
// The number beside the slider: nothing else updates it while dragging.
this.levelLabelTarget.textContent = this.levelTarget.value
// Wait for a pause in typing before sending.
clearTimeout(this.timer)
this.timer = setTimeout(() => this.submit(), 250)
}
submit() {
this.element.requestSubmit()
// A stream response leaves the address bar alone, so mirror the form
// into it here. Leaving out the defaults is more code in this spot,
// and it has to agree with what the Rails controller treats as default.
const query = new URLSearchParams(new FormData(this.element))
history.replaceState({}, "", `${this.element.action}?${query}`)
}
// The pager links fetch streams too, so they need the same treatment.
visited(event) {
history.replaceState({}, "", event.currentTarget.href)
}
disconnect() {
clearTimeout(this.timer)
}
}
Nothing in it is hard. Every live behaviour is wired by hand, though, either as a line in the stream template that names what to replace, or as a Stimulus method for what a stream cannot do. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| The list, pager, status line and count follow a search | Four lines in the stream template, four ids, four instance variables, kept in step by hand | Each effect reads what it shows; nothing lists them |
| Searching as you type | A Stimulus method, wired to each field with data-action |
event: :input on the field |
| The number beside the slider | A Stimulus target and a line of JavaScript | One transmit_value |
| The URL follows the search | JavaScript after each submit and each pager click; defaults stripped in JS to match Ruby | One transmit_url block |
| Remembering the filters between gestures | Every form and link sends all of them back | They are states in the channel |
| Adding a filter | Form field and its data-action, permit list, query |
State, action, query, input |
| Adding a part that shows the results | Instance variable, id, stream template line, partial | One effect and its partial |
| Languages the logic lives in | Ruby and JavaScript | Ruby |
A Turbo Frame around the results would save the stream template, but only while everything that changes sits inside one box. Here the count is in the heading and the controls must not be replaced mid-keystroke, which is what pushes a real page to streams. In the channel, the actions write the filters, and the effects take care of the page from there.