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

Pauper 0 gp

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

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:

  1. The checkbox calls pick with its id and whether it is now ticked. pick adds the id to @selected and returns.
  2. @bank read @selected, so it is built again, with one row picked.
  3. 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.
  4. 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

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