Bulk Selection
A bank of items with a checkbox on each row. The count, the worth and the Sell button follow the ticks, and a filter hides ticked rows without losing them. The selection is one set of ids on the server, so there is no manual work to restore the boxes when the list is redrawn.
Your bank
Nothing selected.
# frozen_string_literal: true
# A visitor's bank. The states are the items, the ids picked, the kind shown
# and the purse. Recipes::Bulk::Bank answers everything else, and each part of the
# page is one effect that asks it.
class Recipes::Bulk::BulkChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Bank = Recipes::Bulk::Bank
Purse = Recipes::Bulk::Purse
Sale = Data.define(:count, :worth, :rank)
VIEWS = "app/recipes/bulk"
def build_graph
@items = Hibiki::State.new(Bank.start.items)
@selected = Hibiki::State.new(Set.new)
@kind = Hibiki::State.new(Bank::ALL)
@sold = Hibiki::State.new(nil)
@purse = Hibiki::State.new(Purse.new)
@bank = Hibiki::Derived.new do
Bank.new(items: @items.value, selected: @selected.value, kind: @kind.value)
end
rows = Hibiki::Derived.new { @bank.value.rows }
tabs = Hibiki::Derived.new { [ @bank.value.kinds, @bank.value.kind ] }
# The rows shown and which of them are ticked. Picking a row that the
# filter hides produces the same rows, so nothing is sent for them.
Hibiki::Effect.new do
broadcast_morph target: "bulk_rows", partial: "#{VIEWS}/rows", locals: { rows: rows.value }
end
# The select-all box, the count, the worth and the buttons.
Hibiki::Effect.new do
broadcast_morph target: "bulk_toolbar", partial: "#{VIEWS}/toolbar",
locals: { bank: @bank.value, sold: @sold.value }
end
# The filter buttons change only with the kinds left and the one chosen.
Hibiki::Effect.new do
tabs.value
broadcast_morph target: "bulk_kinds", partial: "#{VIEWS}/kinds", locals: { bank: @bank.peek }
end
# The balance and the rank. Only a sale writes the purse, so ticking and
# filtering send nothing here.
Hibiki::Effect.new do
broadcast_morph target: "bulk_purse", partial: "#{VIEWS}/purse", locals: { purse: @purse.value }
end
end
# A set, not a toggle: the browser says whether the box is ticked.
def pick(data)
id = data["id"].to_i
return unless @items.value.any? { it.id == id }
change(@selected, data["checked"] == true ? @selected.value | [ id ] : @selected.value - [ id ])
end
# Ticks or clears the rows shown, and leaves the hidden ones as they are.
def pick_all(data)
ids = @bank.value.shown.map(&:id)
change(@selected, data["checked"] == true ? @selected.value | ids : @selected.value - ids)
end
def clear = change(@selected, Set.new)
def filter(data)
kind = data["kind"]
change(@kind, kind) if kind == Bank::ALL || @bank.value.kinds.include?(kind)
end
def sell
bank = @bank.value
return if bank.count.zero?
@items.value = bank.items - bank.picked
@selected.value = Set.new
@purse.value = purse = @purse.value.earn(bank.worth)
@sold.value = Sale.new(count: bank.count, worth: bank.worth, rank: (purse.rank if purse.promoted?))
end
def restock
return unless @items.value.empty?
change(@items, Bank.start.items)
end
private
# Any change after a sale takes its message away.
def change(state, value)
state.value = value
@sold.value = nil
end
end
# frozen_string_literal: true
# What a visitor keeps at the bank, which of it is picked, and the kind
# being looked at. Everything the page says about the selection is a method
# here. The page renders its first state from this, and the channel every
# state after.
class Recipes::Bulk::Bank < Data.define(:items, :selected, :kind)
Item = Data.define(:id, :name, :kind, :price)
Row = Data.define(:item, :picked)
ALL = "all"
# What the bank holds when the page opens.
STOCK = [
"Dragonbone Halberd", "Steel Mace", "Bronze Dagger", "Iron Helm", "Bronze Pickaxe",
"Tinderbox", "Antidote", "Lucky Coin", "Loaf of Bread", "Cabbage"
].freeze
# The starting bank, taken from the shelves of the Live Search recipe.
def self.start
shelves = Recipes::Search::Item.where(name: STOCK).pluck(:id, :name, :kind, :price).map { Item.new(*it) }
new(items: shelves.sort_by { STOCK.index(it.name) })
end
def self.gp(amount) = Recipes::Totals::Order.gp(amount)
def initialize(items:, selected: Set.new, kind: ALL) = super
def empty? = items.empty?
# The kinds there is something of, in the order they first appear.
def kinds = items.map(&:kind).uniq
# A kind with nothing left in it falls back to everything.
def kind = kinds.include?(super) ? super : ALL
def shown = kind == ALL ? items : items.select { it.kind == kind }
def rows = shown.map { Row.new(item: it, picked: selected.include?(it.id)) }
# The selection is a set of ids. An id whose item has gone counts for
# nothing, so nobody has to take it out.
def picked = items.select { selected.include?(it.id) }
def count = picked.size
def worth = picked.sum(&:price)
# Picked, but not among the rows shown.
def hidden_count = count - rows.count(&:picked)
# Where the select-all box stands against the rows shown.
def all_state
shown_picked = rows.count(&:picked)
if shown_picked.zero? then "none"
elsif shown_picked == rows.size then "all"
else "some"
end
end
end
# frozen_string_literal: true
# The coins a visitor has made at the bank, and the standing they buy.
# `sales` counts the sales so far and `gain` is what the last one paid.
class Recipes::Bulk::Purse < Data.define(:coins, :sales, :gain)
# The balance each rank starts from.
RANKS = { 0 => "Pauper", 100 => "Pedlar", 1_000 => "Merchant", 10_000 => "Baron", 50_000 => "Monarch" }.freeze
def initialize(coins: 0, sales: 0, gain: 0) = super
def earn(worth) = with(coins: coins + worth, sales: sales + 1, gain: worth)
def rank = RANKS.select { |from, _| coins >= from }.values.last
# Whether the last sale carried the visitor up a rank.
def promoted? = rank != with(coins: coins - gain).rank
end
<%# The page asks the same Bank the channel does, so the first render and
every later one agree. %>
<% bank = Recipes::Bulk::Bank.start %>
<%= island Recipes::Bulk::BulkChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex flex-wrap items-center justify-between gap-2">
<h2 class="flex items-baseline gap-2 text-xl font-bold">
Your bank
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</h2>
<%= render "app/recipes/bulk/purse", purse: Recipes::Bulk::Purse.new %>
</header>
<%= render "app/recipes/bulk/kinds", bank: %>
<%= render "app/recipes/bulk/toolbar", bank: %>
<%= render "app/recipes/bulk/rows", rows: bank.rows %>
<% end %>
<%# locals: (purse:) -%>
<%# Morphed by the channel after a sale. The balance and the amount paid in
take the sale's number in their ids, so each sale puts in new elements
and their CSS animations play again. %>
<div id="bulk_purse" class="relative flex items-center gap-2">
<span id="bulk_rank" class="badge badge-sm"><%= purse.rank %></span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 text-warning" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="currentColor" stroke="var(--color-base-content)" stroke-width="2"/><path d="M12 7.5v9M9.5 9.5h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" fill="none" stroke="var(--color-base-content)" stroke-width="1.5" stroke-linecap="round"/></svg>
<b id="bulk_coins_<%= purse.sales %>" class="<%= class_names("inline-block tabular-nums", "coin-pop": purse.sales.positive?) %>">
<%= Recipes::Bulk::Bank.gp(purse.coins) %>
</b>
<% if purse.gain.positive? %>
<b id="bulk_gain_<%= purse.sales %>" aria-hidden="true"
class="coin-rise pointer-events-none absolute -top-4 right-0 text-sm whitespace-nowrap text-success">
+<%= Recipes::Bulk::Bank.gp(purse.gain) %>
</b>
<% end %>
</div>
<%# locals: (bank:) -%>
<%# Morphed by the channel when a kind is chosen or the last of a kind is
sold. %>
<div id="bulk_kinds" class="mt-3 flex flex-wrap gap-1.5" role="group" aria-label="Show a kind">
<% [ Recipes::Bulk::Bank::ALL, *bank.kinds ].each do |kind| %>
<%= tag.button kind.capitalize, id: "bulk_kind_#{kind}", type: "button", "aria-pressed": bank.kind == kind,
class: [ "btn btn-xs", { "btn-primary": bank.kind == kind } ],
**on(:filter, with: { kind: }) %>
<% end %>
</div>
<%# locals: (bank:, sold: nil) -%>
<%# Morphed by the channel on every change. It only asks the bank; nothing
is counted here. %>
<% gp = Recipes::Bulk::Bank.method(:gp) %>
<div id="bulk_toolbar" class="mt-3 border-y-2 border-base-300 py-2">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-2">
<%= tag.input id: "bulk_all", type: "checkbox", name: "checked", "aria-label": "Select every item shown",
checked: bank.all_state == "all", disabled: bank.empty?, class: "checkbox checkbox-primary checkbox-sm",
**on(:pick_all, event: :change).deep_merge(props(indeterminate: bank.all_state == "some")) %>
<p id="bulk_count" class="min-w-0 flex-1 text-sm">
<% if bank.count.zero? %>
<span class="opacity-70">Nothing selected.</span>
<% else %>
<b><%= bank.count %> selected</b>, worth <b class="tabular-nums"><%= gp.(bank.worth) %></b>
<% if bank.hidden_count.positive? %>
<span id="bulk_hidden" class="whitespace-nowrap opacity-70">(<%= bank.hidden_count %> not shown)</span>
<% end %>
<% end %>
</p>
<div class="flex basis-full justify-end gap-2 sm:basis-auto">
<% if bank.count.positive? %>
<%= tag.button "Clear", id: "bulk_clear", type: "button", class: "btn btn-ghost btn-sm", **on(:clear) %>
<% end %>
<%= tag.button "Sell selected", id: "bulk_sell", type: "button", class: "btn btn-primary btn-sm",
disabled: bank.count.zero?,
**on(:sell, confirm: "Sell #{pluralize(bank.count, "item")} for #{gp.(bank.worth)}?") %>
</div>
</div>
<% if sold %>
<p id="bulk_sold" class="mt-2 px-2 text-sm">
Sold <%= pluralize(sold.count, "item") %> for <%= gp.(sold.worth) %>.
<%= rainbow_text "You are now a #{sold.rank}!", class: "ml-1 font-bold" if sold.rank %>
</p>
<% end %>
</div>
<%# locals: (rows:) -%>
<%# Morphed by the channel. Rows keep their ids, so the ones that stay are
left where they are and the ones that go slide shut. %>
<div id="bulk_rows">
<ul id="bulk_list">
<%= render partial: "app/recipes/bulk/row", collection: rows %>
</ul>
<% if rows.empty? %>
<div id="bulk_empty" class="flex flex-wrap items-center justify-between gap-2 px-2 py-4">
<p class="text-sm opacity-70">The bank is empty.</p>
<%= tag.button "Fill it again", type: "button", class: "btn btn-sm", **on(:restock) %>
</div>
<% end %>
</div>
<%# locals: (row:) -%>
<%# The tint of a picked row is CSS reading the checkbox. %>
<% item = row.item %>
<li id="bulk_row_<%= item.id %>" data-motion
class="grid grid-rows-[1fr] transition-[grid-template-rows,opacity] duration-300 ease-out
data-motion-leaving:grid-rows-[0fr] data-motion-leaving:opacity-0 motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<label class="flex cursor-pointer items-center gap-3 border-b-2 border-base-300 px-2 py-2 hover:bg-base-200 has-checked:bg-primary/15">
<%= tag.input type: "checkbox", name: "checked", checked: row.picked, class: "checkbox checkbox-primary checkbox-sm",
**on(:pick, event: :change, with: { id: item.id }) %>
<span class="min-w-0 flex-1 font-semibold"><%= item.name %></span>
<span class="badge badge-sm hidden sm:inline-flex"><%= item.kind %></span>
<b class="shrink-0 text-right text-sm whitespace-nowrap tabular-nums sm:w-24"><%= Recipes::Bulk::Bank.gp(item.price) %></b>
</label>
</div>
</li>
The panel above is your bank: ten items with a checkbox on each row, a row of kind buttons above them and a toolbar between. Tick a few rows and the toolbar follows: how many you picked, what they are worth, and the Sell button comes on. Now press Food. The rows you ticked drop out of sight, the toolbar still counts them, and the box at the top now speaks for the rows you can see. Sell what you picked: the rows slide out and your balance goes up. A selection like this usually lives in the browser, in JavaScript that counts the boxes, and has to be put back by hand each time the list is drawn again. Here the selection is a set of ids on the server, and each part of the panel is rendered from it.
How it fits together
The bank lives in a channel: the server side of an Action
Cable connection, an object that stays alive while the page is open. A
state holds a value and remembers who read it. Here three
states hold what the visitor did: @items, what is left in
the bank; @selected, a set of the ids ticked; and
@kind, the kind being looked at. The channel's public
methods are its actions, the methods the page may call.
pick writes @selected, filter
writes @kind, and so on.
A derived is a value worked out from other values, and worked
out again when one of them changes. Here @bank is a derived
that reads the three states and builds a Bank from them: a
plain Ruby value that answers every question the page asks, such as
which rows to show and how many are picked.
An effect is a block of code that runs once, and runs again whenever a value it read has changed. There are four, one for each part of the panel: the rows, the toolbar, the kind buttons and the purse. Each renders its partial and sends it to the page, where it is morphed into place. This is what happens when you tick the Steel Mace:
-
The checkbox calls
pickwith its id and whether it is now ticked.pickadds the id to@selectedand returns. -
@bankread@selected, so it is built again, with one row picked. - The toolbar effect read the bank, so it renders the toolbar again and sends it: 1 selected, worth 260 gp, and the Sell button enabled.
- The rows effect read the rows, which now carry one picked row, so the list is sent again. The kind buttons and the purse read neither, so nothing is sent for them.
The channel never says which effect depends on which state. Whatever a block read on its last run is what it depends on.
The selection is a set of ids
Ask where a selection lives, and the usual answer is the checkboxes:
count the ticked ones and you have it. That answer fails as soon as a
row leaves the page, because a filter or a sale takes the box with it.
Here the selection is the set in @selected, and a checkbox
only shows whether its id is in the set:
# app/models/recipes/bulk/bank.rb
def picked = items.select { selected.include?(it.id) }
def count = picked.size
def worth = picked.sum(&:price)
def hidden_count = count - rows.count(&:picked)
picked reads the set against the items the bank still
holds, so an id whose item has gone counts for nothing.
count and worth are counted from
picked, which is why a row can be hidden and stay
selected. The toolbar reports those as "(1 not shown)", and
sell takes them along with the rest.
Select-all follows the rows shown
A select-all box over a filtered list has to answer a question: all of what? Here it speaks for the rows you can see. It has three states, for none of the rows shown picked, some of them, or all, and the bank answers that in one method. Ticking the box adds the ids shown to the set, and clearing it takes only those away, so what the filter hides is left as it was:
# app/models/recipes/bulk/bank.rb
def shown = kind == ALL ? items : items.select { it.kind == kind }
def rows = shown.map { Row.new(item: it, picked: selected.include?(it.id)) }
def all_state
shown_picked = rows.count(&:picked)
if shown_picked.zero? then "none"
elsif shown_picked == rows.size then "all"
else "some"
end
end
# app/channels/recipes/bulk/bulk_channel.rb
def pick_all(data)
ids = @bank.value.shown.map(&:id)
change(@selected, data["checked"] == true ? @selected.value | ids : @selected.value - ids)
end
shown is the only method that reads the kind, and
rows, all_state and pick_all all
go through it. Press Food and tick the box: the three foods are picked,
the Steel Mace you ticked before still is, and back under All the box
shows "some".
That "some" is the one state HTML cannot say. A checkbox shows it only
when its indeterminate property is set from JavaScript, and
no attribute carries it, so a partial cannot write it the way it writes
checked. Instead the toolbar asks for the property with
props, and the client sets it after each render:
<%# app/views/app/recipes/bulk/_toolbar.html.erb %>
<%= tag.input id: "bulk_all", type: "checkbox", name: "checked",
checked: bank.all_state == "all", disabled: bank.empty?,
**on(:pick_all, event: :change).deep_merge(props(indeterminate: bank.all_state == "some")) %>
The balance
The purse is the coins a visitor has made and the rank they buy. It is a
state of its own, @purse, and only a sale writes it, so a
tick or a change of kind sends nothing to the header. A sale writes four
states in a row:
# app/models/recipes/bulk/purse.rb
def earn(worth) = with(coins: coins + worth, sales: sales + 1, gain: worth)
def rank = RANKS.select { |from, _| coins >= from }.values.last
def promoted? = rank != with(coins: coins - gain).rank
# app/channels/recipes/bulk/bulk_channel.rb
def sell
bank = @bank.value
return if bank.count.zero?
@items.value = bank.items - bank.picked
@selected.value = Set.new
@purse.value = purse = @purse.value.earn(bank.worth)
@sold.value = Sale.new(count: bank.count, worth: bank.worth, rank: (purse.rank if purse.promoted?))
end
sell takes the picked items out of @items,
empties the set, pays the purse and records the sale in
@sold, which the toolbar shows as "Sold 2 items for 5 gp."
until the next change. The four effects follow: the rows slide out, the
toolbar says nothing is selected, and the purse shows the new balance.
A kind with nothing left in it loses its button, and if it was the one
chosen, the bank falls back to All on its own, so sell
never has to reset the filter. The rank is a method that reads the
coins, and promoted? compares it with the rank before the
last sale, so the rainbow line appears only on the sale that earned one.
The same feature in stock Rails
The usual stock Rails build keeps the selection in a Stimulus controller, so that a tick costs no request, and reloads the list in a Turbo Frame when a kind is chosen. Here is a sketch of it. It is not run here, and it leaves out the model and the small partials, which would be the same in both versions.
# A sketch of the stock Rails version. It is not part of this app.
class Bank::ItemsController < ApplicationController
# The filter is a link that reloads the list inside a Turbo Frame. The
# server is never told what is ticked, so it renders every box empty and
# the Stimulus controller ticks them again.
def index
@kind = params[:kind].presence_in(current_bank.kinds) || "all"
@items = current_bank.items_of(@kind)
end
# "Sell selected" posts the ids. The ones the filter hides are not in the
# form, so the Stimulus controller adds a hidden field for each.
def sell
@sold = current_bank.items.where(id: params[:ids])
@count = @sold.size
@worth = @sold.sum(:price)
@ids = @sold.ids
# The coins need somewhere to live between requests, and the two
# writes belong in one transaction.
@rank_before = current_bank.rank
current_bank.transaction do
@sold.destroy_all
current_bank.increment!(:coins, @worth)
current_bank.increment!(:sales)
end
@promoted = current_bank.rank != @rank_before
respond_to do |format|
format.turbo_stream
format.html { redirect_to bank_items_path, notice: "Sold #{@count} items." }
end
end
end
<%# One line for each row that went, and one for each part of the page the
sale moves. The count, the worth, the select-all box and the button are
not here: the Stimulus controller owns those, and is told to work them
out again by the rows leaving. %>
<% @ids.each do |id| %>
<%= turbo_stream.remove "item_#{id}" %>
<% end %>
<%= turbo_stream.update "bank_notice" do %>
Sold <%= pluralize(@count, "item") %> for <%= number_to_gp(@worth) %>.
<%= rainbow_text "You are now a #{current_bank.rank}!" if @promoted %>
<% end %>
<%# The balance, the rank and the amount paid in. Replaced whole, with the
sale's number in the ids, so that the CSS animations play again. %>
<%= turbo_stream.replace "bank_purse", partial: "bank/items/purse",
locals: { bank: current_bank, gain: @worth } %>
<%= turbo_stream.replace "bank_kinds", partial: "bank/items/kinds", locals: { bank: current_bank, kind: "all" } %>
<%= turbo_stream.replace "bank_empty", partial: "bank/items/empty" if current_bank.items.none? %>
<%# The controller sits outside the frame, so that its selection outlives
the list being reloaded. %>
<div data-controller="selection" data-action="turbo:frame-load->selection#restore">
<%= render "bank/items/purse", bank: current_bank, gain: 0 %>
<%= render "bank/items/kinds", bank: current_bank, kind: @kind %>
<%= form_with url: sell_bank_items_path, data: { selection_target: "form", turbo_confirm: "Sell them?",
action: "turbo:submit-end->selection#sold" } do |form| %>
<input type="checkbox" aria-label="Select every item shown"
data-selection-target="all" data-action="selection#toggleAll">
<span data-selection-target="none">Nothing selected.</span>
<span data-selection-target="some" hidden>
<b data-selection-target="count"></b> selected, worth <b data-selection-target="worth"></b>
<span data-selection-target="hidden" hidden></span>
</span>
<button type="button" data-selection-target="clear" data-action="selection#clear" hidden>Clear</button>
<%= form.submit "Sell selected", disabled: true, data: { selection_target: "sell" } %>
<p id="bank_notice"></p>
<%# Hidden ids of what is selected but not shown go in here. %>
<div data-selection-target="extra"></div>
<%= turbo_frame_tag "bank_items" do %>
<ul>
<% @items.each do |item| %>
<%# The price is repeated as data, because the browser adds it up. %>
<li id="item_<%= item.id %>" data-selection-target="row">
<%= check_box_tag "ids[]", item.id, false, id: nil,
data: { selection_target: "box", price: item.price, action: "selection#toggle" } %>
<%= item.name %> <%= item.kind %> <%= number_to_gp(item.price) %>
</li>
<% end %>
</ul>
<% end %>
<div id="bank_empty"></div>
<% end %>
</div>
import { Controller } from "@hotwired/stimulus"
// The selection lives here, in the browser. Everything the page says about
// it is written by `render`, one line for each place it shows.
export default class extends Controller {
static targets = ["form", "all", "box", "row", "none", "some", "count", "worth", "hidden", "clear", "sell", "extra"]
connect() {
this.picked = new Map() // id => price, since a hidden row cannot be asked
this.render()
}
toggle({ target }) {
if (target.checked) this.picked.set(target.value, Number(target.dataset.price))
else this.picked.delete(target.value)
this.render()
}
toggleAll({ target }) {
this.boxTargets.forEach((box) => {
box.checked = target.checked
if (box.checked) this.picked.set(box.value, Number(box.dataset.price))
else this.picked.delete(box.value)
})
this.render()
}
clear() {
this.picked.clear()
this.boxTargets.forEach((box) => (box.checked = false))
this.render()
}
// The frame came back with every box empty. Tick the picked ones again.
restore() {
this.boxTargets.forEach((box) => (box.checked = this.picked.has(box.value)))
this.render()
}
// The sale went through: everything picked is gone.
sold({ detail }) {
if (detail.success) this.picked.clear()
}
// Rows left the page, sold or filtered away. Count again.
rowTargetDisconnected() {
this.render()
}
render() {
const count = this.picked.size
const worth = [...this.picked.values()].reduce((sum, price) => sum + price, 0)
const shown = this.boxTargets.filter((box) => box.checked).length
this.noneTarget.hidden = count > 0
this.someTarget.hidden = count === 0
this.countTarget.textContent = count
this.worthTarget.textContent = `${worth.toLocaleString()} gp` // number_to_gp, a second time
this.hiddenTarget.hidden = count === shown
this.hiddenTarget.textContent = `(${count - shown} not shown)`
this.clearTarget.hidden = count === 0
this.sellTarget.disabled = count === 0
this.allTarget.checked = shown > 0 && shown === this.boxTargets.length
this.allTarget.indeterminate = shown > 0 && shown < this.boxTargets.length
this.allTarget.disabled = this.boxTargets.length === 0
// The form posts only the boxes on the page. Add the rest by hand.
const onPage = new Set(this.boxTargets.map((box) => box.value))
this.extraTarget.replaceChildren(
...[...this.picked.keys()].filter((id) => !onPage.has(id)).map((id) => {
const field = document.createElement("input")
Object.assign(field, { type: "hidden", name: "ids[]", value: id })
return field
})
)
}
}
namespace :bank do
resources :items, only: :index do
post :sell, on: :collection
end
end
Each piece is ordinary. The trouble is that the selection now has two homes: the browser holds it, and the server learns of it only when the form is posted. Each time the frame reloads the list, the controller has to tick the boxes again. Each id the filter hides has to go into the form as a hidden field, and the prices are repeated as data attributes so that the browser can add them up. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| Where the selection lives | In a Stimulus controller, and again in the params when the form is posted | One state holding a set of ids |
| "3 selected, worth 420 gp" | A target for each piece, a line in render for each, and the prices repeated as data attributes |
Two methods on the bank and the toolbar partial |
| The select-all box and its three states | Counted from the checkboxes in JavaScript | One method, all_state, and props(indeterminate:) in the partial |
| Sell is disabled with nothing picked | A target and a line in render |
disabled: bank.count.zero? in the partial |
| The selection survives the filter | Keep the controller outside the frame, and tick the boxes again on turbo:frame-load |
Nothing; the rows are rendered from the set |
| Selling rows the filter hides | A hidden field for each id that is not on the page | Nothing; the action reads the set |
| After a sale | A stream template listing each part to update, and a handler in the controller to forget the ids that were sold | The action writes its states; the four effects follow |
| The balance and the rank | Columns for the coins and the sales, a transaction in sell, the rank compared before and after, and two more entries in the stream template |
A state, a write in sell and an effect |
| Formatting a price | Twice: a Rails helper and toLocaleString |
Once, in Ruby |
| What a tick costs | Nothing; it stays in the browser | One round trip over the open socket |
The last row is the honest cost: here every tick is a round trip over the open socket. The stock version avoids that by keeping a second copy of the selection in the browser, and most of its code is there to keep the two copies agreeing. Here there is one copy, on the server, and Hibiki carries its state to the page.