Searchable Multi-select
An apothecary's potions, each picking its ingredients from 120 jars. Filter the list as you type, and a jar you picked stays picked when the filter hides it. The selection is one array on the server, so the page needs no JavaScript of its own.
The apothecary's shelf
Shared with every visitor. Put back each night.
# frozen_string_literal: true
# The apothecary's shelf. Which potion is open belongs to this tab, and so
# do the two things its picker holds: what has been picked, which is the
# form's `ingredient_ids`, and what has been typed in the filter. The
# filter chooses which jars are listed and nothing else. The potions belong
# to everyone: a save here or in another tab bumps `@version`, and they are
# read again.
class Recipes::Multiselect::MultiselectChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Potion = Recipes::Multiselect::Potion
Ingredient = Recipes::Multiselect::Ingredient
PotionForm = Recipes::Multiselect::PotionForm
VIEWS = "app/recipes/multiselect"
# How many jars the list offers at once.
LIMIT = 12
def build_graph
@version = Hibiki::State.new(0)
@editing_id = Hibiki::State.new(nil)
@query = Hibiki::State.new("")
# One form serves every potion; `edit` fills it.
@form = PotionForm.new
# The rows are plain values, so a save that changes nothing sends nothing.
rows = Hibiki::Derived.new do
@version.value
Potion.rows
end
# The jars on offer. It reads the filter and never the selection. One
# row more than the limit is asked for, and that row is the "type to
# narrow" note, so nothing is counted on a keystroke.
offered = Hibiki::Derived.new do
jars = Ingredient.named(@query.value).order(:name).limit(LIMIT + 1).pluck(:name, :id)
{ jars: jars.first(LIMIT), more: jars.size > LIMIT }
end
# What is picked, by name. It reads the selection and never the filter.
picked = Hibiki::Derived.new { Ingredient.where(id: @form.ingredient_ids).order(:name).pluck(:name, :id) }
# The shelf is one fragment. It is rendered again when the potions
# change, when one opens or closes, when a jar is picked or put back,
# when the filter offers other jars, and when a save is turned down.
# The name is read without subscribing to it, so typing one renders
# only when it turns Save on or off.
Hibiki::Effect.new do
locals = { rows: rows.value, editing_id: @editing_id.value, form: @form }
picker = { picked: picked.value, **offered.value, dirty: @form.dirty?, errors: @form.errors } if locals[:editing_id]
Hibiki.untrack do
# The filter's text goes back into its field, which a morph would
# otherwise empty once the cursor has left it for a box.
locals[:picker] = { **picker, query: @query.value } if picker
broadcast_morph target: "multiselect_potions", partial: "#{VIEWS}/potions", locals:
end
end
end
def edit(data)
potion = Potion.find_by(id: data["id"])
return unless potion
@form.hydrate(potion)
# Each potion opens with every jar on offer.
@query.value = ""
@editing_id.value = potion.id
end
def cancel(_data) = @editing_id.value = nil
# The filter. It writes one state, and the selection is not in it.
def search(data) = @query.value = data["query"].to_s.first(40)
# One box ticked or cleared, or a chip's cross. The box says which way it
# went, so sending it twice changes nothing.
def toggle(data)
return unless @editing_id.value
# What the browser sent is looked up before it goes into the form.
id = Ingredient.where(id: data["id"]).pick(:id)
return unless id
ids = @form.ingredient_ids
if !data["checked"]
@form.ingredient_ids = ids - [ id ]
elsif @form.room?
@form.ingredient_ids = ids | [ id ]
end
end
# Keeps the form up to date with the name, so a render caused by a pick
# puts back what was typed.
def set_field(data)
return unless @editing_id.value
assign(data)
end
# The name comes from the browser. The ingredients do not: they are
# already in the form.
def save(data)
return unless @editing_id.value
assign(data)
return unless @form.dirty?
# A save the model turns down leaves its messages on the form, and the
# potion stays open.
return unless @form.commit
@editing_id.value = nil
@version.value += 1
end
private
def subscribed
super
return if subscription_rejected?
# A potion saved in another tab. The block runs on a cable thread, so
# it hops onto the graph thread before touching a signal.
stream_from Potion::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
def assign(data)
name = data["name"]
@form.name = name.first(60) if name.is_a?(String)
end
end
# frozen_string_literal: true
# The form in an open potion of the Searchable Multi-select recipe. The
# name is a signal, and so is the selection: `ingredient_ids`, an array of
# ids. `hydrate` reads it from the potion and `commit` hands it back, and
# the model writes the measures.
class Recipes::Multiselect::PotionForm
include Hibiki::Rails::ReactiveForm
Potion = Recipes::Multiselect::Potion
reactive_attributes "Recipes::Multiselect::Potion", :name
reactive_association :ingredients
def room? = ingredient_ids.size < Potion::MAX_INGREDIENTS
end
# frozen_string_literal: true
# A potion on the apothecary's shelf, in the Searchable Multi-select
# recipe: a name and the ingredients that go in it. Every visitor edits
# the same potions, so what a visitor types is public. Recipes::Reset puts
# them back each night.
class Recipes::Multiselect::Potion < ApplicationRecord
self.table_name = "recipes_multiselect_potions"
# A potion as the shelf shows it: a plain copy, compared by what it says.
Row = Data.define(:id, :name, :ingredients)
MAX_INGREDIENTS = 8
SEED_FILE = Rails.root.join("db/seeds/apothecary.yml")
# Every open shelf listens here for a potion saved by someone else.
CHANGED = "recipes:multiselect:potions"
has_many :measures, class_name: "Recipes::Multiselect::Measure", inverse_of: :potion, dependent: :destroy
# `dependent: :destroy` is about the measures: one taken out is destroyed,
# not deleted, so its `touch` runs.
has_many :ingredients, -> { order(:name) }, through: :measures, dependent: :destroy
after_commit { self.class.announce }
validates :name, presence: { message: "A potion needs a name." },
length: { maximum: 40, message: "Forty letters is all the label has room for." }
validate :ingredients_within_limits
def self.rows = includes(:ingredients).order(:id).map(&:row)
# A ping with nothing in it. Whoever hears it reads the shelf again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# Loads the stores and the shelf from db/seeds/apothecary.yml, over
# whatever is there. Potions and jars keep their ids; what goes in each
# potion is written again. Safe to run again.
def self.seed!
seeds = YAML.load_file(SEED_FILE)
transaction do
Recipes::Multiselect::Ingredient.seed!(seeds)
upsert_all(seeds["potions"].map { it.slice("slug", "name") }, unique_by: :slug)
Recipes::Multiselect::Measure.delete_all
Recipes::Multiselect::Measure.insert_all!(measures_of(seeds["potions"]))
end
end
# Nothing in `seed!` runs a callback, so the ping is sent by hand.
def self.reset!
seed!
announce
end
def self.measures_of(potions)
potion_ids = pluck(:slug, :id).to_h
ingredient_ids = Recipes::Multiselect::Ingredient.pluck(:name, :id).to_h
potions.flat_map do |potion|
potion["ingredients"].map { { potion_id: potion_ids.fetch(potion["slug"]), ingredient_id: ingredient_ids.fetch(it) } }
end
end
private_class_method :measures_of
def row = Row.new(id:, name:, ingredients: ingredients.map(&:name))
private
def ingredients_within_limits
errors.add(:ingredients, "A potion needs at least one ingredient.") if ingredients.empty?
errors.add(:ingredients, "A potion takes #{MAX_INGREDIENTS} ingredients at most.") if ingredients.size > MAX_INGREDIENTS
end
end
# frozen_string_literal: true
# One ingredient in one potion, in the Searchable Multi-select recipe.
class Recipes::Multiselect::Measure < ApplicationRecord
self.table_name = "recipes_multiselect_measures"
# A measure added or taken out is a change to the potion: `touch` commits
# the potion too, and the potion's ping tells every open shelf.
belongs_to :potion, class_name: "Recipes::Multiselect::Potion", inverse_of: :measures, touch: true
belongs_to :ingredient, class_name: "Recipes::Multiselect::Ingredient"
end
# frozen_string_literal: true
# A jar in the apothecary's stores, in the Searchable Multi-select recipe.
# Visitors pick from the jars and never write them.
class Recipes::Multiselect::Ingredient < ApplicationRecord
self.table_name = "recipes_multiselect_ingredients"
# The jars whose name holds the term, or all of them.
scope :named, ->(term) { term.blank? ? all : where(arel_table[:name].matches("%#{sanitize_sql_like(term.strip)}%")) }
# Every preparation of every thing in db/seeds/apothecary.yml.
def self.names(seeds)
seeds.fetch("preparations").product(seeds.fetch("things")).map { it.join(" ") }
end
# A jar keeps its id. Safe to run again.
def self.seed!(seeds) = upsert_all(names(seeds).map { { name: it } }, unique_by: :name)
end
<%# The page reads the same rows the channel does. No potion is open yet. %>
<%= island Recipes::Multiselect::MultiselectChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h2 class="flex items-baseline gap-2 text-xl font-bold">
The apothecary's shelf
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</h2>
<p class="text-sm opacity-70">Shared with every visitor. Put back each night.</p>
</header>
<%= render "app/recipes/multiselect/potions", rows: Recipes::Multiselect::Potion.rows %>
<% end %>
<%# locals: (rows:, editing_id: nil, form: nil, picker: nil) -%>
<%# Morphed by the channel. Each potion keeps its id whether it is open or
not, so the ones that did not change are left as they are. %>
<div id="multiselect_potions" class="mt-3">
<ul id="multiselect_list">
<% rows.each do |row| %>
<li id="multiselect_potion_<%= row.id %>" class="border-b-2 border-base-300">
<% if row.id == editing_id %>
<%= render "app/recipes/multiselect/form", form:, picker: %>
<% else %>
<%= render "app/recipes/multiselect/potion", row: %>
<% end %>
</li>
<% end %>
</ul>
</div>
<%# locals: (row:) -%>
<%# A closed potion is one button, so the keyboard can open it too. %>
<%= tag.button type: "button", id: "multiselect_show_#{row.id}",
class: "group block w-full cursor-pointer px-2 py-2 text-left hover:bg-base-200",
**on(:edit, with: { id: row.id }) do %>
<span class="flex items-baseline gap-3">
<span class="min-w-0 flex-1 font-semibold"><%= row.name %></span>
<span class="text-xs opacity-0 group-hover:opacity-60 group-focus-visible:opacity-60">edit</span>
</span>
<span class="block text-sm opacity-70"><%= row.ingredients.to_sentence %></span>
<% end %>
<%# locals: (form:, picker:) -%>
<%# An open potion. The form holds the name and nothing else: the picker is
outside it, so Enter in the filter saves nothing, and no box is ever
part of what Save sends. Save is tied to the form by its `form`
attribute. Escape goes on the wrapper, and is heard from the picker too. %>
<% id = form.record.id %>
<%= tag.div id: "multiselect_edit_#{id}", class: "bg-base-200 px-2 py-3", **on(:cancel, event: "keydown.esc") do %>
<%= tag.form id: "multiselect_form_#{id}", **on(:save, event: :submit, reset: false) do %>
<%= tag.input type: "text", name: "name", id: "multiselect_#{id}_name", value: form.name, maxlength: 60,
autocomplete: "off", aria: { label: "Name" }, class: "input input-sm w-full text-sm font-semibold",
**on(:set_field, event: :input) %>
<% end %>
<%= render "app/recipes/multiselect/picker", id:, form:, **picker.slice(:picked, :jars, :more, :query) %>
<% picker[:errors].each_value do |messages| %>
<p class="mt-2 text-sm text-error"><%= messages.first %></p>
<% end %>
<div class="mt-3 flex items-center gap-2">
<%# Off until there is something to save. Not `disabled`: a browser
will not submit a form on Enter while its button is. %>
<%= tag.button "Save", type: "submit", form: "multiselect_form_#{id}",
class: [ "btn btn-primary btn-sm", ("btn-disabled" unless picker[:dirty]) ], aria: { disabled: !picker[:dirty] } %>
<%= tag.button "Cancel", type: "button", class: "btn btn-sm", **on(:cancel) %>
</div>
<% end %>
<%# locals: (id:, form:, picked:, jars:, more:, query:) -%>
<%# Two views of one array of ids. The chips are every id in it. The list
is whatever the filter offers, and a box is ticked when its id is in
the array. A box is named "checked", not "ingredient_ids[]": it tells
the channel which way it went, and is never read as the selection. %>
<% max = Recipes::Multiselect::Potion::MAX_INGREDIENTS %>
<p id="multiselect_<%= id %>_count" class="mt-3 text-sm">
<b class="tabular-nums"><%= picked.size %></b> of <%= max %> in the potion
<% unless form.room? %><span class="opacity-70">· full; take one out to add another</span><% end %>
</p>
<ul id="multiselect_<%= id %>_picked" class="mt-1 flex flex-wrap gap-1">
<% picked.each do |name, jar| %>
<li id="multiselect_<%= id %>_chip_<%= jar %>" class="badge badge-primary gap-1 pr-1">
<%= name %>
<%= tag.button "×", type: "button", id: "multiselect_#{id}_drop_#{jar}", aria: { label: "Take out #{name}" },
class: "btn btn-ghost btn-xs btn-circle size-4 min-h-0", **on(:toggle, with: { id: jar, checked: false }) %>
</li>
<% end %>
<% if picked.empty? %>
<li class="text-sm opacity-70">Nothing yet.</li>
<% end %>
</ul>
<%= tag.input type: "search", name: "query", id: "multiselect_#{id}_query", value: query, maxlength: 40, autocomplete: "off",
placeholder: "Filter the stores", aria: { label: "Filter the stores" }, class: "input input-sm mt-3 w-full text-sm",
**on(:search, event: :input).deep_merge(focus) %>
<ul id="multiselect_<%= id %>_jars" class="mt-2 grid gap-x-3 sm:grid-cols-2">
<% jars.each do |name, jar| %>
<% ticked = form.ingredient_ids.include?(jar) %>
<li id="multiselect_<%= id %>_jar_<%= jar %>">
<label class="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-base-300 has-checked:font-semibold has-disabled:cursor-default has-disabled:opacity-50">
<%= tag.input type: "checkbox", name: "checked", checked: ticked, disabled: !ticked && !form.room?,
class: "checkbox checkbox-primary checkbox-xs", **on(:toggle, event: :change, with: { id: jar }) %>
<span><%= name %></span>
</label>
</li>
<% end %>
</ul>
<p id="multiselect_<%= id %>_note" class="mt-1 text-sm opacity-70">
<% if jars.empty? %>
No jar by that name.
<% elsif more %>
Showing the first <%= jars.size %>. Type to narrow.
<% end %>
</p>
The shelf above holds the apothecary's potions, each with the ingredients that go in it. Type toad in the filter and tick a jar, then type nettle and tick another. The first jar has left the list, and it is still in the potion. A picker like this is tedious to build in stock Rails without reaching for a JavaScript library. Here the selection is kept on the server, so the list can be easily adjusted to your liking, in Ruby. The shelf is shared with every visitor and resets each night.
How it fits together
An island is the part of the page Hibiki looks after, here the whole shelf. It belongs to a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each tab. The channel's public methods are its actions, the methods the page may call, and its states are values that remember who read them. Here the states are which potion is open, what is typed in the filter, and a version that goes up when anyone saves a potion.
A reactive form holds a record's attributes as states of its
own, one per field, so the page can edit them before anything is
written back. Here the open potion is a PotionForm. One
line in it, reactive_association :ingredients, adds a
state called ingredient_ids: an array of ids, filled from
the potion when it opens. That array is the selection. This is what
happens when you tick a box:
-
The box calls
togglewith the jar's id and whether it is now ticked. - The action looks the id up, checks there is room (eight jars at most) and writes the array again with the id in it.
- The effect that renders the shelf read the array, so it runs again and morphs the open potion into the page: a chip appears, the count goes up, and Save turns on. The filter keeps what was typed, since its text is a state too.
Where the selection is kept
In a plain form the selection is whichever boxes are ticked when the form is sent. A filter is dangerous there: a box that is not on the page is not sent, so a jar hidden by the filter reads as not picked. Here the selection is an array on the server, and it changes one box at a time.
# app/forms/recipes/multiselect/potion_form.rb
reactive_attributes "Recipes::Multiselect::Potion", :name
reactive_association :ingredients # a signal, `ingredient_ids`
# The channel. A box sends its id and whether it is ticked.
def toggle(data)
id = Ingredient.where(id: data["id"]).pick(:id)
return unless id
ids = @form.ingredient_ids
if !data["checked"]
@form.ingredient_ids = ids - [ id ]
elsif @form.room?
@form.ingredient_ids = ids | [ id ]
end
end
A box says which way it went, ticked or cleared, so the same message
sent twice picks the jar once. The id came from the browser, so it is
looked up before it goes in. The limit of eight is the form's
room?, and the model checks it again at the save.
Everything that shows the selection reads the same array: the chips,
the count, which boxes are ticked, which boxes go off when the potion
is full, and dirty?, which compares the array and the name
with how the potion was when it opened. If you pick a jar and take it out
again, the equality check happens, and the Save button turns off through reactivity without us telling it to.
The filter and the list
The picker shows two lists that come from the database: the jars on
offer, which depend on what is typed in the filter, and the chips,
which depend on what is picked. A keystroke changes the jars on offer
and leaves the chips as they are. A tick changes the chips and leaves
the jars on offer as they are. We want each list to be looked up again
only when its own input changes, so each list is a derived of
its own. A derived is a value computed from states and computed again
when one of the states it read changes. The filter writes a state
called @query, and the derived for the jars on offer reads
that state. The derived for the chips reads the form's
ingredient_ids. Typing in the filter runs the search of
the stores again and does not look the chips up. Ticking a box looks
the chips up again and does not run the search.
@query = Hibiki::State.new("")
def search(data) = @query.value = data["query"].to_s.first(40)
# Reads the filter, and never the selection.
offered = Hibiki::Derived.new do
jars = Ingredient.named(@query.value).order(:name).limit(LIMIT + 1).pluck(:name, :id)
{ jars: jars.first(LIMIT), more: jars.size > LIMIT }
end
# Reads the selection, and never the filter.
picked = Hibiki::Derived.new { Ingredient.where(id: @form.ingredient_ids).order(:name).pluck(:name, :id) }
The list has room for twelve jars. When more than twelve match what was
typed, the visitor should be told to keep typing. The obvious way to
know that is to count the matches, and that is a second query on every
keystroke. Instead the derived asks for thirteen jars, which is the
LIMIT + 1 in the snippet, and shows the first twelve. If a
thirteenth jar comes back, there are more than the list can show, and
that is what more: records. The partial reads it and puts
the note "Type to narrow" under the list.
<%# A chip for every id in the selection. %>
<% picked.each do |name, jar| %>
<li>
<%= name %>
<%= tag.button "×", type: "button", **on(:toggle, with: { id: jar, checked: false }) %>
</li>
<% end %>
<%# A box for every jar the filter offers. Its name is "checked": the
client sends the box's own state under it. %>
<% jars.each do |name, jar| %>
<% ticked = form.ingredient_ids.include?(jar) %>
<label>
<%= tag.input type: "checkbox", name: "checked", checked: ticked, disabled: !ticked && !form.room?,
**on(:toggle, event: :change, with: { id: jar }) %>
<%= name %>
</label>
<% end %>
Each box needs a name. If the boxes were named
ingredient_ids[], they would act like a field of the
potion, and a plain form would send the ticked boxes it can see when
Save is pressed. The boxes here are named checked instead.
A box sends its own id and whether it is now ticked, the moment it
changes, and the channel writes the array from that. The selection is
never read from the boxes.
The picker also sits outside the form element. The form
holds the name field alone, and Save reaches it through the button's
form attribute. So Save sends the name and nothing from
the picker, and hitting Enter in the filter does not trigger a save.
Saving
Until you press Save, nothing has been written. Picking a jar changes
the array in the form, and the potion in the table is as it was when
it opened. Save is when the array becomes rows. The channel's
save action takes the name from the page, asks the form
whether anything changed, and calls commit.
commit calls update on the potion with the
name and the array of ids.
def save(data)
return unless @editing_id.value
assign(data) # the name; the ids are already in the form
return unless @form.dirty?
return unless @form.commit # potion.update(name:, ingredient_ids:)
@editing_id.value = nil
@version.value += 1
end
Rails does the rest. The potion has
has_many :ingredients, through: :measures, so assigning
ingredient_ids writes the measures table. Rails compares
the new ids with the ids the potion already has. It inserts a measure
for each id that is new, and destroys the measure of each id that is
gone. The name and the measures are saved in one transaction.
The model refuses some potions. A potion needs a name, at least one ingredient, and eight ingredients at most. When the model refuses, the transaction is rolled back, so the name and the measures stay in the table as they were. The form keeps the name and the array as you left them, the model's message appears under the picker, and the potion stays open. To see this, take every jar out of a potion and press Save.
One more thing is needed so that other visitors hear about the save.
Every open shelf listens for the potion's after_commit,
which broadcasts that the potion changed. A save that only picks a jar
writes a row in the measures table and leaves the potion's own columns
as they were. So the measure's belongs_to :potion has
touch: true: a measure that is inserted or destroyed
stamps the potion's updated_at, and the potion is
committed along with it. Taking a jar out destroys its measure rather
than deleting it (the association has dependent: :destroy),
so the touch runs on the way out as well. A save that changes only the
ingredients therefore commits the potion, and the broadcast reaches
every open shelf.
With the generator
We wrote this picker by hand. On a resource made by
hibiki:rails:scaffold, one command adds the same kind of
picker as a dropdown, with the join model and the lines that wire it
in.
bin/rails g hibiki:rails:multiselect Potion Ingredient Measure
The same feature in stock Rails
Stock Rails has collection_select with
multiple: true, which draws every option and has no
filter. The usual answer is a JavaScript picker such as
Tom Select, fed by a JSON
endpoint. Here is a sketch. It is not run here.
# A sketch of the stock Rails version. It is not part of this app.
class PotionsController < ApplicationController
before_action :set_potion, only: %i[show edit update]
def index
@potions = Potion.includes(:ingredients).order(:id)
end
# Cancel. The potion's frame asks for the closed potion again.
def show
render partial: "potions/potion", locals: { potion: @potion }
end
def edit
end
def update
if @potion.update(potion_params)
render partial: "potions/potion", locals: { potion: @potion }
else
# The picker is built again from the potion, which still holds what
# was picked.
render :edit, status: :unprocessable_entity
end
end
private
def set_potion = @potion = Potion.find(params[:id])
# The selection arrives here, all of it, when the form is sent.
def potion_params = params.expect(potion: [ :name, ingredient_ids: [] ])
end
# The picker's options. One more than the limit is read, to know whether
# there are more.
class IngredientsController < ApplicationController
LIMIT = 12
def index
jars = Ingredient.named(params[:q]).order(:name).limit(LIMIT + 1).pluck(:id, :name)
render json: { jars: jars.first(LIMIT).map { |id, name| { id:, name: } }, more: jars.size > LIMIT }
end
end
class Potion < ApplicationRecord
MAX_INGREDIENTS = 8
has_many :measures, dependent: :destroy
has_many :ingredients, through: :measures, dependent: :destroy
validates :name, presence: true, length: { maximum: 40 }
validates :ingredients, length: { in: 1..MAX_INGREDIENTS }
# Other visitors. The stream replaces the line inside the potion's frame,
# so a visitor who has the potion open keeps their form. A measure
# touches its potion, so this runs when only the ingredients changed.
after_update_commit do
broadcast_replace_to "potions", target: dom_id(self, :line),
partial: "potions/line", locals: { potion: self }
end
end
<%= turbo_stream_from "potions" %>
<ul>
<% @potions.each do |potion| %>
<li><%= render "potions/potion", potion: %></li>
<% end %>
</ul>
<%# A closed potion: a frame, and in it a link to the form. %>
<%= turbo_frame_tag potion do %>
<%= link_to edit_potion_path(potion) do %>
<%= render "potions/line", potion: %>
<% end %>
<% end %>
<div id="<%= dom_id(potion, :line) %>">
<b><%= potion.name %></b>
<%= potion.ingredients.map(&:name).to_sentence %>
</div>
<%# The same frame id as the closed potion, so Turbo swaps one for the
other. A save that is turned down renders this again with a 422. %>
<%= turbo_frame_tag @potion do %>
<%= form_with model: @potion,
data: { controller: "picker", picker_url_value: ingredients_path, picker_max_value: Potion::MAX_INGREDIENTS,
action: "keydown.esc->picker#cancel input->picker#changed" } do |form| %>
<%= form.text_field :name, data: { picker_target: "name" } %>
<p data-picker-target="count"></p>
<%# The select starts with the picked jars as its only options: the
picker has to know their names before anyone searches. The helper
adds a hidden blank field, so a potion with nothing picked still
sends the parameter. %>
<%= form.collection_select :ingredient_ids, @potion.ingredients, :id, :name, {},
multiple: true, data: { picker_target: "select" } %>
<p data-picker-target="more" hidden>Showing the first 12. Type to narrow.</p>
<% @potion.errors.each do |error| %>
<p><%= error.full_message %></p>
<% end %>
<%= form.submit "Save", data: { picker_target: "save" } %>
<%= link_to "Cancel", potion_path(@potion), data: { picker_target: "cancel" } %>
<% end %>
<% end %>
import { Controller } from "@hotwired/stimulus"
import TomSelect from "tom-select"
// Until Save, the selection is here and nowhere else: in the <select>,
// which Tom Select keeps in step with the chips it draws. So everything
// the page says about the selection is worked out here too.
export default class extends Controller {
static targets = ["select", "name", "count", "more", "save", "cancel"]
static values = { url: String, max: Number }
connect() {
this.opened = this.snapshot()
this.picker = new TomSelect(this.selectTarget, {
valueField: "id",
labelField: "name",
searchField: "name",
maxItems: this.maxValue,
plugins: ["remove_button"],
preload: true,
// Ask the server on every search, not once per term.
shouldLoad: () => true,
load: (query, done) => this.load(query, done),
onChange: () => this.changed()
})
this.changed()
}
disconnect() {
this.picker.destroy()
}
async load(query, done) {
try {
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`,
{ headers: { Accept: "application/json" } })
const { jars, more } = await response.json()
// Drop the options of the last search, but not the picked ones:
// an item whose option is gone is gone from the selection.
this.picker.clearOptions(option => this.picker.items.includes(String(option.id)))
this.moreTarget.hidden = !more
done(jars)
} catch {
done()
}
}
// The count, the limit and whether there is anything to save.
changed() {
const picked = this.picker.items.length
this.countTarget.textContent = `${picked} of ${this.maxValue} in the potion`
this.countTarget.classList.toggle("full", picked >= this.maxValue)
this.saveTarget.disabled = this.snapshot() === this.opened
}
snapshot() {
const picked = [...this.selectTarget.selectedOptions].map(option => option.value).sort()
return JSON.stringify([this.nameTarget.value, picked])
}
cancel() {
this.cancelTarget.click()
}
}
resources :potions, only: %i[index show edit update]
# The options, as JSON, for the picker to ask as it is typed in.
resources :ingredients, only: :index
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| Where the selection is before Save | In the page's select, kept by the library |
In one state on the server |
| A pick the filter hides | Kept as long as its option stays in the select; clearing the old options has to skip the picked ones |
Nothing; the list is not the selection |
| The options | A route, a JSON action and a fetch in the controller |
One derived that reads the filter |
| The picked jars' names when the form opens | Rendered into the select as its first options |
One derived that reads the selection |
| "Type to narrow" | A flag in the JSON, and a target to show and hide | A flag in the derived, read by the partial |
| The count and the limit | Counted in JavaScript; the limit is passed in as a value | Read from the form by the partial |
| Save only when something changed | Compare the fields with a copy taken when the form opened | form.dirty? |
| An id the browser made up | ActiveRecord raises RecordNotFound at the save |
Looked up in toggle, and dropped |
| What may be saved | params.expect with ingredient_ids: [] |
The name; the ids never come from the form |
| A refused save | Render the form again with a 422, and build the picker again | Nothing; the effect follows the form's errors |
| Arrow keys in the list, and matches highlighted | Built into the library | Not here; Tab and Space work, as on any checkbox |
| JavaScript you write | One Stimulus controller of about sixty lines, and the library | None |
| What a pick costs | Nothing; it happens in the browser | One round trip over the open socket |
| With the socket down | Picking still works; searching needs the server | The boxes wait for the socket |
Tom Select is a good picker, and it does more with the keyboard than this page does. With 120 jars the stock version could also render every option and skip the endpoint; the endpoint is for a list too long to send. In return, the browser holds the selection until Save, so it has to work out everything the page says about it, and it has to take care never to drop an option it is holding. In the Hibiki version the selection is on the server and the page is rendered from it, so the browser doesn't need to work out the list again.
Both versions assume that when two visitors save the same potion, the last save wins. If you need to keep a shared row safe from a second save, Presence and Conflicts has some worked-out solutions.