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

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:

  1. The box calls toggle with the jar's id and whether it is now ticked.
  2. The action looks the id up, checks there is room (eight jars at most) and writes the array again with the id in it.
  3. 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 table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
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.