Dependent Selects
Three dependent selects for booking a cart: select a kingdom, a region and a town. Each select decides what the next one offers, without the update being declared by hand. The address bar follows each change, so a journey can be opened from a link.
Book a cart
Nowhere to go yet.
# frozen_string_literal: true
# Booking a cart: kingdom, then region, then town. The three states hold what
# the visitor last picked at each level. What a select offers, and whether a
# pick still counts, are derived from the level above, so no action ever
# clears anything.
class Recipes::Selects::SelectsChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Place = Recipes::Selects::Place
Choice = Recipes::Selects::Choice
VIEWS = "app/recipes/selects"
def build_graph
# The tab subscribes with the journey its URL named, so a reloaded or
# shared link opens with all three selects filled in.
start = Choice.from(params)
@kingdom = Hibiki::State.new(start.kingdom)
@region = Hibiki::State.new(start.region)
@town = Hibiki::State.new(start.town)
@booked = Hibiki::State.new(false)
# A pick counts only while the list above still offers it. Change the
# kingdom and the region is no longer on the list, so the towns empty
# and the town stops counting too.
@regions = Hibiki::Derived.new { Place.under(@kingdom.value).options }
region = Hibiki::Derived.new { offered(@regions.value, @region.value) }
@towns = Hibiki::Derived.new { Place.under(region.value).options }
town = Hibiki::Derived.new { offered(@towns.value, @town.value) }
@choice = Hibiki::Derived.new { Choice.new(kingdom: @kingdom.value, region: region.value, town: town.value) }
# A select is sent again when its options change, not when its own value
# does: `peek` reads the selection without subscribing to it, so the
# select someone is using is never replaced under them.
Hibiki::Effect.new do
broadcast_replace target: "selects_region", partial: "#{VIEWS}/level",
locals: { level: :region, options: @regions.value, selected: @choice.peek.region }
end
Hibiki::Effect.new do
broadcast_replace target: "selects_town", partial: "#{VIEWS}/level",
locals: { level: :town, options: @towns.value, selected: @choice.peek.town }
end
Hibiki::Effect.new do
broadcast_replace target: "selects_summary", partial: "#{VIEWS}/summary",
locals: { places: @choice.value.places, booked: @booked.value }
end
transmit_url { urls.app_recipe_path("selects", **@choice.value.to_query) }
end
def set_kingdom(data) = pick(@kingdom, data["kingdom"], Place.kingdoms.options)
def set_region(data) = pick(@region, data["region"], @regions.value)
def set_town(data) = pick(@town, data["town"], @towns.value)
def book = @booked.value = @choice.value.town.present?
private
# Whatever the browser sends, only something on the list is kept.
def pick(state, slug, options)
state.value = offered(options, slug)
@booked.value = false
end
def offered(options, slug) = options.rassoc(slug)&.last
def urls = Rails.application.routes.url_helpers
end
# frozen_string_literal: true
# Somewhere the cart can go in the Dependent Selects recipe. Kingdoms have no
# parent, regions sit under a kingdom and towns under a region.
class Recipes::Selects::Place < ApplicationRecord
self.table_name = "recipes_selects_places"
SEED_FILE = Rails.root.join("db/seeds/places.yml")
belongs_to :parent, class_name: name, optional: true
scope :kingdoms, -> { where(parent_id: nil) }
# The places directly under the one with this slug. Under nothing, none.
scope :under, ->(slug) { slug ? where(parent_id: unscoped.where(slug:).select(:id)) : none }
# What a select offers: name and slug pairs, as plain arrays.
def self.options = order(:name).pluck(:name, :slug)
# Loads the map from db/seeds/places.yml, parents first. Safe to run again.
def self.seed!(places = YAML.load_file(SEED_FILE), parent_id: nil)
places.each do |place|
row = { name: place["name"], slug: place["name"].parameterize, parent_id:, fare: place["fare"] }
id = upsert(row, unique_by: :slug, returning: :id).first["id"]
seed!(place.fetch("places", []), parent_id: id)
end
end
end
# frozen_string_literal: true
# Where a visitor wants to go, as the address bar carries it: up to three
# slugs. `from` reads a query string (or the params a tab subscribes with)
# and keeps a level only if the level above offers it, so a link can never
# name a town outside its region.
class Recipes::Selects::Choice < Data.define(:kingdom, :region, :town)
Place = Recipes::Selects::Place
def self.from(params)
kingdom = Place.kingdoms.where(slug: params[:kingdom].to_s).pick(:slug)
region = Place.under(kingdom).where(slug: params[:region].to_s).pick(:slug)
town = Place.under(region).where(slug: params[:town].to_s).pick(:slug)
new(kingdom:, region:, town:)
end
def initialize(kingdom: nil, region: nil, town: nil) = super
# The places chosen so far, kingdom first.
def places
slugs = to_h.values.compact
Place.where(slug: slugs).index_by(&:slug).values_at(*slugs)
end
def to_query = to_h.compact
end
<%# The page starts from the journey in its URL, and hands the same one to
the channel as it subscribes. %>
<% choice = Recipes::Selects::Choice.from(params) %>
<% places = Recipes::Selects::Place %>
<%= island Recipes::Selects::SelectsChannel, params: choice.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">Book a cart</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<div class="mt-3 grid gap-3 sm:grid-cols-3">
<%= render "app/recipes/selects/level", level: :kingdom, options: places.kingdoms.options, selected: choice.kingdom %>
<%= render "app/recipes/selects/level", level: :region, options: places.under(choice.kingdom).options, selected: choice.region %>
<%= render "app/recipes/selects/level", level: :town, options: places.under(choice.region).options, selected: choice.town %>
</div>
<%= render "app/recipes/selects/summary", places: choice.places %>
<% end %>
<%# locals: (level:, options:, selected: nil) -%>
<%# One select and its label. A level with nothing to offer is disabled and
says which select to use first. %>
<% above = { region: "kingdom", town: "region" }[level] %>
<% prompt = options.empty? ? "Pick a #{above} first" : "Choose a #{level}" %>
<label id="selects_<%= level %>" class="flex flex-col gap-1">
<span class="text-sm font-semibold"><%= level.to_s.capitalize %></span>
<%= tag.select name: level, class: "select w-full", disabled: options.empty?,
**on(:"set_#{level}", event: :change) do %>
<%= options_for_select [ [ prompt, "" ], *options ], selected %>
<% end %>
</label>
<%# locals: (places:, booked: false) -%>
<%# The journey so far. The button works only once there is a town. %>
<% town = places[2] %>
<div id="selects_summary" class="mt-4 flex min-h-12 flex-wrap items-center gap-x-3 gap-y-2 border-t-2 border-base-300 pt-3">
<p class="min-w-0 flex-1 text-sm">
<% if places.empty? %>
<span class="opacity-70">Nowhere to go yet.</span>
<% else %>
<%= safe_join places.map(&:name), " › " %>
<% if town %>
<b class="ml-1 whitespace-nowrap tabular-nums"><%= town.fare %> gp</b>
<% end %>
<% end %>
</p>
<%= tag.button "Set off", type: "button", class: "btn btn-primary btn-sm", disabled: town.nil? || booked, **on(:book) %>
<% if booked %>
<p class="w-full text-sm">The cart leaves for <%= town.name %> at dawn. Mind the cabbages.</p>
<% end %>
</div>
The form above books a cart with three selects: a kingdom, a region in it and a town in that region. Each select offers only what sits under the one before it, and changing the kingdom empties the two below, along with the fare and the button. The address bar follows, so a journey can be opened from a link. Stock Rails leaves all of this to hand-written JavaScript. Here each level is written once, as what it depends on, and the emptying takes care of itself.
How it fits together
The picks live in a channel: the server side of an Action
Cable connection, which stays alive while the page is open. A
state holds a value and remembers who read it. Here
@kingdom, @region and @town are
states, one for what the visitor last picked at each level. The
channel's public methods are its actions, and
set_kingdom, set_region and
set_town each write one of them.
A derived is a value worked out from other values, and worked
out again when one of them changes. The option lists are deriveds:
@regions reads @kingdom and asks the database
for the regions under it, and @towns does the same under
the region.
An effect is a block of code that runs once, and runs again whenever a value it read has changed. There are three: one for the region select, one for the town select and one for the summary, each rendering its partial and sending it to the page.
Reactivity resets the lower selects
The usual way to write a chain of selects is a reset: when the kingdom changes, set the region and the town to nothing. That works, but every level has to know about all the levels below it. Here the chain is written as what each level depends on:
# in build_graph
@regions = Hibiki::Derived.new { Place.under(@kingdom.value).options }
region = Hibiki::Derived.new { offered(@regions.value, @region.value) }
@towns = Hibiki::Derived.new { Place.under(region.value).options }
town = Hibiki::Derived.new { offered(@towns.value, @town.value) }
# the whole of the kingdom action
def set_kingdom(data) = pick(@kingdom, data["kingdom"], Place.kingdoms.options)
The whole kingdom action is the last line. This is what happens when you change the kingdom from Northmarch to Sunmere:
-
@regionsread@kingdom, so it is now the list of Sunmere's regions. -
Greyfen is not on that list, so
regionbecomes nil. -
@townsreadregion, and under nothing there are no towns, sotownbecomes nil too. - The effects read those, so each sends its part again: the region select with new options, the town select empty and disabled, and the summary with the kingdom alone.
Go back to Northmarch and the old region and town return, because they are on the list again; the states held them all along. The same check guards against bad input, since an action keeps a value only if the list offers it.
The select in use is never replaced
Replacing a select while someone is using it takes the focus away, which keyboard users notice at once. So a select is sent again only when its options change, and never because of its own value:
Hibiki::Effect.new do
broadcast_replace target: "selects_region", partial: "#{VIEWS}/level",
locals: { level: :region,
options: @regions.value, # subscribes: new options, new select
selected: @choice.peek.region } # does not: picking a region sends nothing here
end
peek reads a value without subscribing to it. The effect
reads @regions, so new options mean a new select, and peeks
at the chosen region, so choosing one sends nothing here. Choosing a
region re-runs the town select and the summary, and leaves the region
select alone.
A link opens all three levels
A choice that lives in a socket is gone when the page reloads, unless
the page writes it down somewhere. Here it goes in the URL: the
transmit_url block reads @choice and sends a
new path whenever it changes. When that link is opened, the page and
the channel both start from Choice.from, which walks down
from the kingdom and keeps a level only if the level above offers it. A link
that names a town in the wrong region opens with the kingdom alone.
Adding a fourth level
Say each town has inns. The new level is a state, two deriveds, an
effect and an action, plus inn in Choice and a
fourth render in the page:
# in build_graph
@inn = Hibiki::State.new(start.inn)
@inns = Hibiki::Derived.new { Place.under(town.value).options }
inn = Hibiki::Derived.new { offered(@inns.value, @inn.value) }
Hibiki::Effect.new do
broadcast_replace target: "selects_inn", partial: "#{VIEWS}/level",
locals: { level: :inn, options: @inns.value, selected: @choice.peek.inn }
end
def set_inn(data) = pick(@inn, data["inn"], @inns.value)
Nothing above it was edited. set_kingdom does not learn
that inns exist, and changing the kingdom still empties them, through
the same chain.
The same feature in stock Rails
The usual stock Rails build is a JSON endpoint for each dependent select and a Stimulus controller that fetches the list and rebuilds the options. Here is a sketch of it. It is not run here, and it leaves out the model, which would be the same in both versions.
import { Controller } from "@hotwired/stimulus"
// Everything a chain of selects needs that Rails does not do for you.
export default class extends Controller {
static targets = ["kingdom", "region", "town", "summary", "button"]
static values = { regionsUrl: String, townsUrl: String }
async kingdomChanged() {
// The reset, by hand. Every level below the one that changed has to be
// emptied here, so a fourth level means editing this method too.
this.fill(this.townTarget, [], "Pick a region first")
this.fill(this.regionTarget, await this.load(this.regionsUrlValue, this.kingdomTarget.value), "Choose a region")
this.refresh()
}
async regionChanged() {
this.fill(this.townTarget, await this.load(this.townsUrlValue, this.regionTarget.value), "Choose a town")
this.refresh()
}
townChanged() {
this.refresh()
}
// A slow reply to an earlier choice can arrive after a later one and
// fill the select with the wrong list. Guarding against that is more
// code, left out here.
async load(url, slug) {
if (!slug) return []
const response = await fetch(url.replace(":slug", slug), { headers: { Accept: "application/json" } })
return response.json()
}
// Rebuilds a select's options, which the server rendered the first time.
fill(select, places, prompt) {
select.replaceChildren(new Option(prompt, ""))
for (const place of places) {
const option = new Option(place.name, place.slug)
if (place.fare) option.dataset.fare = place.fare
select.add(option)
}
select.disabled = places.length === 0
}
// The summary, the button and the address bar: each one is a line that
// has to be called from every method above.
refresh() {
const chosen = [this.kingdomTarget, this.regionTarget, this.townTarget]
.map((select) => select.selectedOptions[0])
.filter((option) => option?.value)
const names = chosen.map((option) => option.text).join(" › ")
const fare = this.townTarget.selectedOptions[0]?.dataset.fare
this.summaryTarget.textContent = names ? `${names}${fare ? ` ${fare} gp` : ""}` : "Nowhere to go yet."
this.buttonTarget.disabled = !this.townTarget.value
const query = new URLSearchParams(chosen.map((option) => [option.parentElement.name, option.value]))
history.replaceState({}, "", `${this.element.action}?${query}`)
}
}
# A sketch of the stock Rails version. It is not part of this app.
class JourneysController < ApplicationController
# First load, reload, shared link. Each list is loaded here for the page,
# and again by the actions below for the JavaScript.
def new
@choice = Choice.from(params)
@kingdoms = Place.kingdoms.options
@regions = Place.under(@choice.kingdom).options
@towns = Place.under(@choice.region).options
@places = @choice.places
end
# Called by the Stimulus controller when the kingdom changes.
def regions
render json: Place.under(params[:kingdom]).order(:name).as_json(only: %i[name slug])
end
# Called when the region changes. The fare rides along, because the
# summary is written in JavaScript and has nowhere else to get it.
def towns
render json: Place.under(params[:region]).order(:name).as_json(only: %i[name slug fare])
end
end
# A sketch of the stock Rails version. It is not part of this app.
resource :journey, only: :new
# One endpoint for each select that depends on another. A fourth level is a
# third line here.
get "kingdoms/:kingdom/regions", to: "journeys#regions", as: :kingdom_regions
get "regions/:region/towns", to: "journeys#towns", as: :region_towns
<%# Each select is a target and carries its own data-action. The endpoints
are handed to the controller as values, with a placeholder where the
chosen slug goes. %>
<%= form_with url: new_journey_path, method: :get,
data: { controller: "journey",
journey_regions_url_value: kingdom_regions_path(":slug"),
journey_towns_url_value: region_towns_path(":slug") } do |form| %>
<%= form.select :kingdom, @kingdoms, { selected: @choice.kingdom, prompt: "Choose a kingdom" },
data: { journey_target: "kingdom", action: "change->journey#kingdomChanged" } %>
<%= form.select :region, @regions, { selected: @choice.region, prompt: "Choose a region" },
disabled: @regions.empty?,
data: { journey_target: "region", action: "change->journey#regionChanged" } %>
<%# Each town option needs its fare as a data attribute, for the summary. %>
<%= form.select :town, town_options_with_fares(@towns, @choice.town), { prompt: "Choose a town" },
disabled: @towns.empty?,
data: { journey_target: "town", action: "change->journey#townChanged" } %>
<%# Written here for the first load, and rewritten by the controller
after that: the same sentence in ERB and in JavaScript. %>
<p data-journey-target="summary"><%= journey_summary(@places) %></p>
<%= form.button "Set off", disabled: @choice.town.nil?, data: { journey_target: "button" } %>
<% end %>
Each piece is small. The options are rendered twice, though: once in ERB for the first load and once in JavaScript after that, and the order in which things empty is spelled out by hand. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| A select offers what sits under the one above | A route and a JSON action per level, a fetch, and JavaScript that builds the options | A derived and an effect; the partial that drew the select draws it again |
| Emptying what is below a changed select | Each change handler empties every level below it | Nothing; a pick counts only while it is offered |
| Disabling a select with nothing to offer | A line of JavaScript, and the same rule in ERB | One disabled: in the partial |
| The summary and the button | Targets, a method every handler calls, and the fare passed through data- attributes |
One effect and its partial |
| The URL follows the choice | JavaScript that collects the selects and calls replaceState |
One transmit_url block |
| Two quick changes in a row | Replies can arrive out of order; guarding against it is more JavaScript | Actions run one at a time, in the order sent |
| Adding a level | Route, action, target, handler, and an edit to each handler above | State, two deriveds, effect, action |
| Languages the logic lives in | Ruby and JavaScript | Ruby |
Stock Rails has a second way with no JSON: submit the whole form on every change and replace it with a Turbo Frame or a stream. That removes the option-building JavaScript, but every change sends every field, the focus has to be kept by morphing, and the reset is still written by hand, now on the server. Here each action writes one pick, and what the selects offer follows from there.