Nested Forms
A caravan route, its stops and the cargo at each stop, edited as one form. Add/remove a stop, drag and drop to reorder. The rows are kept on the server, so the only JavaScript needed here is for the drag and drop.
Caravan routes
Shared with every visitor. Put back each night.
The Salt Road
9 days-
1
Sunmere Salt × 12 and Dried fish × 62 days
-
2
The Gnome Mine Pickaxes × 34 days
-
3
Castle Gate Iron bars × 20 and Salt × 43 days
The Cabbage Run
7 days-
1
The Cabbage Patch Cabbages × 401 day
-
2
The Inn at the Ford Cabbages × 15 and Ale × 82 days
-
3
Sunmere no trade3 days
-
4
The Smithy Coal × 101 day
# frozen_string_literal: true
# The caravan routes. Which route is open belongs to this tab, and so does
# everything done to the open form before Save: stops added, removed and
# moved are writes to the form's signals. The routes belong to everyone: a
# save here or in another tab bumps `@version`, and they are read again.
class Recipes::Nested::NestedChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
# nested_add, nested_remove, nested_move and nested_set_field. Each finds
# its form by the `path` the control sent ("stops/c3/cargos/n1").
include Hibiki::Rails::NestedActions
Route = Recipes::Nested::Route
RouteForm = Recipes::Nested::RouteForm
VIEWS = "app/recipes/nested"
CHANGED_UNDER = "The route changed while you were at it. Here it is now."
def build_graph
@version = Hibiki::State.new(0)
@editing_id = Hibiki::State.new(nil)
@notice = Hibiki::State.new(nil)
# One form serves every route; `edit` fills it.
@form = RouteForm.new
# The rows are plain values, so a save that changes nothing sends nothing.
rows = Hibiki::Derived.new do
@version.value
Route.rows
end
# The routes are one fragment. It is rendered again when they change,
# when a route opens or closes, when the open form gains, loses or
# moves a row, when it starts or stops being dirty, and when a save is
# turned down. The fields are read without subscribing to them, so
# typing renders nothing.
Hibiki::Effect.new do
locals = { rows: rows.value, editing_id: @editing_id.value, form: @form, notice: @notice.value }
[ @form.shape, @form.dirty?, @form.problems ] if @editing_id.value
Hibiki.untrack do
broadcast_morph target: "nested_routes", partial: "#{VIEWS}/routes", locals:
end
end
# The one thing typing does send: the days, added up.
transmit_value(:nested_days) { @form.days }
end
def edit(data)
route = Route.find_by(id: data["id"])
return unless route
@notice.value = nil
@form.hydrate(route)
@editing_id.value = route.id
end
def cancel(_data)
@editing_id.value = nil
@notice.value = nil
end
# The route's own field. The stops' fields go to `nested_set_field`.
def set_field(data)
return unless @editing_id.value
assign(data)
end
def nested_set_field(data) = super(data.transform_values { it.is_a?(String) ? it.first(60) : it })
def save(data)
return unless @editing_id.value
assign(data)
# One save for the route, its stops and their cargo. A save the models
# turn down leaves each message on the form it belongs to, and the
# route stays open.
return unless @form.dirty?
# Forget the stops read when the route was opened, so the save looks
# for each one again and raises when one is gone.
@form.record.stops.reset
return unless @form.commit
cancel(data)
@version.value += 1
rescue ActiveRecord::RecordNotFound
# Another visitor removed a stop this form holds, or the routes were
# put back. Start again from the route as it is.
reopen
end
private
def subscribed
super
return if subscription_rejected?
# A route saved in another tab. The block runs on a cable thread, so it
# hops onto the graph thread before touching a signal.
stream_from Route::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
def reopen
route = Route.find_by(id: @editing_id.value)
return cancel(nil) unless route
@form.hydrate(route)
@notice.value = CHANGED_UNDER
@version.value += 1
end
# Only the form's own attributes are taken from what the browser sent.
def assign(data)
RouteForm.hibiki_attributes.each do |name|
value = data[name.to_s]
@form.public_send(:"#{name}=", value.first(60)) if value.is_a?(String)
end
end
end
# frozen_string_literal: true
# The form in an open route of the Nested Forms recipe. The name is a
# signal, and so is the list of stops: an array of StopForms, each holding
# its own fields and its own list of cargo. One `commit` saves the tree.
class Recipes::Nested::RouteForm
include Hibiki::Rails::ReactiveForm
Route = Recipes::Nested::Route
reactive_attributes "Recipes::Nested::Route", :name
reactive_nested :stops, "Recipes::Nested::StopForm"
# Follows the days fields as they are typed.
derived(:days) { kept.sum { it.days.to_i } }
# What the form looks like apart from what is typed in it: which stops
# and cargo lines there are, in what order, and which are marked to go.
# It changes on add, remove and move, and never on a keystroke.
derived(:shape) { stops.map { [ it.nested_key, it.marked_for_destruction?, it.shape ] } }
# The stops the save will keep, in order. A saved stop that was removed
# stays in `stops`, marked, until the save.
def kept = stops.reject(&:marked_for_destruction?)
def leaving = stops.count(&:marked_for_destruction?)
def room? = kept.size < Route::MAX_STOPS
# No more stops than the route may keep.
def nested_add(name) = (super if room?)
# The order of the stops is the order of the array, so the position
# column is written from it.
def to_h
super.tap { |tree| tree[:stops_attributes].each_with_index { |stop, position| stop[:position] = position } }
end
# Every message in the tree. A save turned down a second time can leave
# the route's own messages as they were and move one between two stops.
def problems = [ errors, stops.map(&:problems) ]
end
# frozen_string_literal: true
# One stop in an open route. It nests again: a stop has cargo.
class Recipes::Nested::StopForm
include Hibiki::Rails::ReactiveForm
Stop = Recipes::Nested::Stop
reactive_attributes "Recipes::Nested::Stop", :place, :days
reactive_nested :cargos, "Recipes::Nested::CargoForm"
def kept = cargos.reject(&:marked_for_destruction?)
def room? = kept.size < Stop::MAX_CARGO
def nested_add(name) = (super if room?)
def shape = cargos.map { [ it.nested_key, it.marked_for_destruction? ] }
def problems = [ errors, cargos.map(&:errors) ]
end
# frozen_string_literal: true
# One line of cargo at a stop.
class Recipes::Nested::CargoForm
include Hibiki::Rails::ReactiveForm
reactive_attributes "Recipes::Nested::Cargo", :goods, :quantity
end
# frozen_string_literal: true
# A caravan route in the Nested Forms recipe: a name, its stops in order,
# and the cargo traded at each stop. Every visitor edits the same routes,
# so what a visitor types is public. Recipes::Reset puts them back each
# night.
class Recipes::Nested::Route < ApplicationRecord
self.table_name = "recipes_nested_routes"
# A route as the page shows it: plain copies, compared by what they say.
Row = Data.define(:id, :name, :stops) do
def days = stops.sum(&:days)
end
StopRow = Data.define(:place, :days, :cargo)
CargoRow = Data.define(:goods, :quantity)
MAX_STOPS = 6
SEED_FILE = Rails.root.join("db/seeds/routes.yml")
# Every open page listens here for a route saved by someone else.
CHANGED = "recipes:nested:routes"
has_many :stops, -> { order(:position, :id) }, class_name: "Recipes::Nested::Stop", inverse_of: :route,
dependent: :destroy
accepts_nested_attributes_for :stops, allow_destroy: true
# A stop that is saved or removed touches its route, so this runs once
# for a save anywhere in the tree.
after_commit { self.class.announce }
validates :name, presence: { message: "A route needs a name." },
length: { maximum: 40, message: "Forty letters is all the signpost has room for." }
validate :stops_within_limits
def self.rows = includes(stops: :cargos).order(:id).map(&:row)
# A ping with nothing in it. Whoever hears it reads the routes again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# Loads the routes from db/seeds/routes.yml, over whatever is there. A
# route keeps its id; its stops and cargo are written again. Safe to run
# again.
def self.seed!
seeds = YAML.load_file(SEED_FILE)
transaction do
upsert_all(seeds.map { it.slice("slug", "name") }, unique_by: :slug)
Recipes::Nested::Cargo.delete_all
Recipes::Nested::Stop.delete_all
seeds.each { |seed| where(slug: seed["slug"]).pick(:id).then { load_stops(it, seed["stops"]) } }
end
end
# Nothing in `seed!` runs a callback, so the ping is sent by hand.
def self.reset!
seed!
announce
end
def self.load_stops(route_id, stops)
stops.each_with_index do |stop, position|
stop_id = Recipes::Nested::Stop.insert!({ route_id:, position:, **stop.slice("place", "days") }).first["id"]
cargo = stop["cargo"].map { { stop_id:, **it } }
Recipes::Nested::Cargo.insert_all!(cargo) if cargo.any?
end
end
private_class_method :load_stops
def row = Row.new(id:, name:, stops: stops.map(&:row))
private
# Counted from the stops the save will leave, so a stop marked to go
# makes room for a new one.
def stops_within_limits
kept = stops.reject(&:marked_for_destruction?).size
errors.add(:stops, "A route needs at least one stop.") if kept.zero?
errors.add(:stops, "A caravan makes #{MAX_STOPS} stops at most.") if kept > MAX_STOPS
end
end
# frozen_string_literal: true
# A stop on a caravan route: where, how many days the leg to it takes, and
# its place in the order.
class Recipes::Nested::Stop < ApplicationRecord
self.table_name = "recipes_nested_stops"
MAX_CARGO = 3
MAX_DAYS = 30
belongs_to :route, class_name: "Recipes::Nested::Route", inverse_of: :stops, touch: true
has_many :cargos, -> { order(:id) }, class_name: "Recipes::Nested::Cargo", inverse_of: :stop, dependent: :delete_all
accepts_nested_attributes_for :cargos, allow_destroy: true
validates :place, presence: { message: "A stop needs a place." },
length: { maximum: 40, message: "Forty letters is all the map has room for." }
validates :days, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: MAX_DAYS,
message: "A leg takes 1 to #{MAX_DAYS} days." }
validate :cargo_within_limit
def row = Recipes::Nested::Route::StopRow.new(place:, days:, cargo: cargos.map(&:row))
private
def cargo_within_limit
return if cargos.reject(&:marked_for_destruction?).size <= MAX_CARGO
errors.add(:cargos, "A stop trades #{MAX_CARGO} goods at most.")
end
end
# frozen_string_literal: true
# Goods traded at a stop, and how many.
class Recipes::Nested::Cargo < ApplicationRecord
self.table_name = "recipes_nested_cargos"
MAX_QUANTITY = 99
belongs_to :stop, class_name: "Recipes::Nested::Stop", inverse_of: :cargos, touch: true
validates :goods, presence: { message: "Name the goods." },
length: { maximum: 40, message: "Forty letters is all the manifest has room for." }
validates :quantity, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: MAX_QUANTITY,
message: "A cart carries 1 to #{MAX_QUANTITY}." }
def row = Recipes::Nested::Route::CargoRow.new(goods:, quantity:)
end
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
import { performOn } from "hibiki-rails"
// A list whose rows are dragged into a new order. SortableJS does the
// dragging; a drop tells the island which row went where. Each row names
// itself with `data-path`, and the grip is the element with
// `data-drag-handle`.
export default class extends Controller {
static values = { dom: String }
connect() {
this.sortable = Sortable.create(this.element, {
handle: "[data-drag-handle]",
animation: 150,
// Sortable's own drag, not the browser's: the same on every device.
forceFallback: true,
onEnd: (event) => this.dropped(event)
})
}
disconnect() {
this.sortable.destroy()
}
dropped({ item, oldIndex, newIndex }) {
if (newIndex === oldIndex) return
// A number back means the move was sent and a render is on its way,
// which will agree with where the row now is.
const accepted = performOn(this.element, "nested_move", { dom: this.domValue, path: item.dataset.path, to: newIndex })
if (accepted) return
// Nothing back means the island is offline and no render is coming,
// so the row goes back where it was.
const others = [...this.element.children].filter((row) => row !== item)
this.element.insertBefore(item, others[oldIndex] ?? null)
}
}
<%# The page reads the same rows the channel does. No route is open yet. %>
<%= island Recipes::Nested::NestedChannel, 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">
Caravan routes
<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/nested/routes", rows: Recipes::Nested::Route.rows %>
<% end %>
<%# locals: (rows:, editing_id: nil, form: nil, notice: nil) -%>
<%# Morphed by the channel. A route keeps its id whether it is open or not,
so the routes that did not change are left as they are. %>
<div id="nested_routes" class="mt-3 grid gap-3">
<% rows.each do |row| %>
<section id="nested_card_<%= row.id %>" class="rounded-box border-2 border-base-300 bg-base-200 p-3">
<% if row.id == editing_id %>
<%= render "app/recipes/nested/form", form:, notice: %>
<% else %>
<%= render "app/recipes/nested/route", row: %>
<% end %>
</section>
<% end %>
</div>
<%# locals: (row:) -%>
<%# A closed route: its stops in order, and what is traded at each. %>
<header class="flex items-center gap-3">
<h3 class="min-w-0 flex-1 text-lg font-bold"><%= row.name %></h3>
<span class="text-sm whitespace-nowrap tabular-nums opacity-70"><%= pluralize row.days, "day" %></span>
<%= tag.button "Edit", type: "button", id: "nested_edit_#{row.id}", class: "btn btn-sm",
aria: { label: "Edit #{row.name}" }, **on(:edit, with: { id: row.id }) %>
</header>
<ol class="mt-2 grid gap-1 text-sm">
<% row.stops.each.with_index(1) do |stop, number| %>
<li class="flex items-baseline gap-x-3">
<span class="w-4 shrink-0 text-right tabular-nums opacity-60"><%= number %></span>
<div class="min-w-0 flex-1 sm:flex sm:items-baseline sm:gap-x-3">
<b class="block flex-1"><%= stop.place %></b>
<span class="block opacity-70"><%= stop.cargo.map { "#{it.goods} × #{it.quantity}" }.to_sentence.presence || "no trade" %></span>
</div>
<span class="w-14 shrink-0 text-right whitespace-nowrap tabular-nums"><%= pluralize stop.days, "day" %></span>
</li>
<% end %>
</ol>
<%# locals: (form:, notice: nil) -%>
<%# An open route. `dom` names this form and `path` names a row in it; the
nested_* actions find their form by the two. The list of stops is also
a SortableJS list: a drop calls `nested_move`, as the arrows do. Rows
marked to go are not drawn, so a row's place in the list is the index
`nested_move` counts in. %>
<% dom = "nested_route_#{form.record.id}" %>
<% stops = form.kept %>
<%= tag.div id: "#{dom}_edit", **on(:cancel, event: "keydown.esc") do %>
<%= tag.form id: "#{dom}_form", **on(:save, event: :submit, reset: false) do %>
<%= tag.input type: "text", name: "name", id: "#{dom}_name", value: form.name, maxlength: 60,
autocomplete: "off", aria: { label: "Route name" }, class: "input input-sm w-full font-bold",
**on(:set_field, event: :input).deep_merge(focus(select: true)) %>
<%= render "app/recipes/nested/errors", messages: form.errors[:name] %>
<%= tag.ol id: "#{dom}_stops", class: "mt-3 grid gap-2",
data: { controller: "sortable", sortable_dom_value: dom } do %>
<% stops.each_with_index do |stop, index| %>
<%= render "app/recipes/nested/stop_fields", stop:, dom:, index:, count: stops.size %>
<% end %>
<% end %>
<%= render "app/recipes/nested/errors", messages: form.errors[:stops] %>
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<%= tag.button "Add a stop", type: "button", id: "#{dom}_add", class: "btn btn-sm", disabled: !form.room?,
**on(:nested_add, with: { dom:, path: "stops" }) %>
<% if form.leaving.positive? %>
<span class="text-sm opacity-70"><%= pluralize form.leaving, "stop" %> <%= form.leaving == 1 ? "leaves" : "leave" %> the route on save.</span>
<% end %>
<span class="ml-auto text-sm whitespace-nowrap">
Days on the road: <b class="tabular-nums"><%= reactive :nested_days, form.days %></b>
</span>
</div>
<% if notice %>
<p class="mt-2 text-sm text-warning"><%= notice %></p>
<% end %>
<div class="mt-3 flex items-center gap-2 border-t-2 border-base-300 pt-3">
<%# Off until there is something to save. Not `disabled`: a browser
will not submit a form on Enter while its button is, and Enter
can come before the render that turns the button on. %>
<%= tag.button "Save", type: "submit", class: [ "btn btn-primary btn-sm", ("btn-disabled" unless form.dirty?) ],
aria: { disabled: !form.dirty? } %>
<%= tag.button "Cancel", type: "button", class: "btn btn-sm", **on(:cancel) %>
</div>
<% end %>
<% end %>
<%# locals: (stop:, dom:, index:, count:) -%>
<%# One stop. A field is named by its path, and says which field it is, so
one action serves every field at every depth. A stop added here has no
id yet; its key is n1, n2, and so on. %>
<% path = "stops/#{stop.nested_key}" %>
<% row = "#{dom}_#{path.tr('/', '_')}" %>
<% field = ->(name) { on(:nested_set_field, event: :input, with: { dom:, path:, field: name }) } %>
<% move = ->(to) { on(:nested_move, with: { dom:, path:, to: }) } %>
<%= tag.li id: row, class: "rounded-field border-2 border-base-300 bg-base-100 p-2", data: { path: } do %>
<div class="flex flex-wrap items-center gap-2">
<span class="cursor-grab px-1 text-lg leading-none opacity-60 select-none" title="Drag to reorder" aria-hidden="true" data-drag-handle>⠿</span>
<%= tag.input type: "text", name: "#{path}/place", id: "#{row}_place", value: stop.place, maxlength: 60,
autocomplete: "off", placeholder: "Where", aria: { label: "Place" },
class: "input input-sm min-w-0 flex-1 basis-40 text-sm",
**(stop.persisted? ? field["place"] : field["place"].deep_merge(focus)) %>
<label class="input input-sm ml-auto w-24">
<%= tag.input type: "number", name: "#{path}/days", id: "#{row}_days", value: stop.days, min: 1,
max: Recipes::Nested::Stop::MAX_DAYS, inputmode: "numeric", aria: { label: "Days" },
class: "text-sm tabular-nums", **field["days"] %>
<span class="opacity-70">days</span>
</label>
<div class="join">
<%= tag.button "↑", type: "button", id: "#{row}_up", class: "btn btn-sm btn-square join-item", disabled: index.zero?,
title: "Move up", aria: { label: "Move up" }, **move[index - 1] %>
<%= tag.button "↓", type: "button", id: "#{row}_down", class: "btn btn-sm btn-square join-item", disabled: index == count - 1,
title: "Move down", aria: { label: "Move down" }, **move[index + 1] %>
</div>
<%= tag.button "✕", type: "button", id: "#{row}_remove", class: "btn btn-ghost btn-sm btn-square", title: "Remove the stop",
aria: { label: "Remove the stop" }, **on(:nested_remove, with: { dom:, path: }) %>
</div>
<div class="pl-1 sm:pl-8"><%= render "app/recipes/nested/errors", messages: stop.errors.slice(:place, :days).values.map(&:first) %></div>
<%= tag.ul id: "#{row}_cargos", class: "mt-2 grid gap-1 pl-1 sm:pl-8" do %>
<% stop.kept.each do |cargo| %>
<%= render "app/recipes/nested/cargo_fields", cargo:, dom:, path: "#{path}/cargos/#{cargo.nested_key}" %>
<% end %>
<% end %>
<div class="mt-1 pl-1 sm:pl-8">
<%= render "app/recipes/nested/errors", messages: stop.errors[:cargos] %>
<%= tag.button "Add cargo", type: "button", id: "#{row}_add", class: "btn btn-ghost btn-xs", disabled: !stop.room?,
**on(:nested_add, with: { dom:, path: "#{path}/cargos" }) %>
</div>
<% end %>
<%# locals: (cargo:, dom:, path:) -%>
<%# One line of cargo, a level further down. Only the path is longer. %>
<% row = "#{dom}_#{path.tr('/', '_')}" %>
<% field = ->(name) { on(:nested_set_field, event: :input, with: { dom:, path:, field: name }) } %>
<%= tag.li id: row do %>
<div class="flex items-center gap-2">
<%= tag.input type: "text", name: "#{path}/goods", id: "#{row}_goods", value: cargo.goods, maxlength: 60,
autocomplete: "off", placeholder: "Goods", aria: { label: "Goods" },
class: "input input-xs min-w-0 flex-1 text-sm",
**(cargo.persisted? ? field["goods"] : field["goods"].deep_merge(focus)) %>
<span class="opacity-70" aria-hidden="true">×</span>
<%= tag.input type: "number", name: "#{path}/quantity", id: "#{row}_quantity", value: cargo.quantity, min: 1,
max: Recipes::Nested::Cargo::MAX_QUANTITY, inputmode: "numeric", aria: { label: "Quantity" },
class: "input input-xs w-16 text-sm tabular-nums", **field["quantity"] %>
<%= tag.button "✕", type: "button", id: "#{row}_remove", class: "btn btn-ghost btn-xs btn-square", title: "Remove the cargo",
aria: { label: "Remove the cargo" }, **on(:nested_remove, with: { dom:, path: }) %>
</div>
<%= render "app/recipes/nested/errors", messages: cargo.errors.values.map(&:first) %>
<% end %>
The panel above lists two caravan routes, each with its stops in order and the cargo traded at each stop. Edit and reorder the routes as a nested form. Changes don't reach the database until you press Save. Pressing Cancel or hitting the Escape key discards any changes made. The open form lives on the server, and the only JavaScript we wrote is for the drag and drop. The routes are shared with every visitor and are reset each night.
How it fits together
An island is the part of the page Hibiki looks after, here the whole panel. 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 route is open and a version that goes up when anyone saves a route.
A reactive form holds a record's attributes as states of its
own, one per field, so the page can edit and check them before
anything is written back. Here the open route is a
RouteForm. Its list of stops is one more state, holding an
array of forms, one per stop, and each stop holds its cargo the same
way. Adding, removing and moving a stop are writes to that list. The
channel includes Hibiki::Rails::NestedActions, which
brings the four actions the rows need: nested_add,
nested_remove, nested_move and
nested_set_field. We wrote none of them. This is what
happens when you press Add a stop:
-
The button calls
nested_addwithdom, the open form, andpath, the list in it: herestops. -
The action finds the list by the two, checks there is room (six
stops at most) and writes the array again with a new
StopFormat the end. - The effect that renders the routes read the list of stops, so it runs again and morphs the open form into the page, with the new row at the foot and the cursor in its place field. Save turns on, since the tree now differs from how it was when the route opened.
A form of forms
class RouteForm
include Hibiki::Rails::ReactiveForm
reactive_attributes "Route", :name
reactive_nested :stops, "StopForm" # a signal holding an array of forms
end
class StopForm
include Hibiki::Rails::ReactiveForm
reactive_attributes "Stop", :place, :days
reactive_nested :cargos, "CargoForm" # the third level is this line
end
class CargoForm
include Hibiki::Rails::ReactiveForm
reactive_attributes "Cargo", :goods, :quantity
end
Each level is a form class with one line for the level below.
reactive_nested :stops declares the list of stop forms
over the model's has_many :stops, and the models declare
accepts_nested_attributes_for as they would for any
nested form in Rails. The third level is the same line again in
StopForm.
Adding a stop puts a new form in the array. Removing a stop the table
has marks it to go, and removing one that was never saved drops it.
Moving a stop writes the array in a new order. Each of these is a
write to the list, so whatever read the list runs again: the rows,
the count of stops that are leaving, and dirty?, which
compares the whole tree with how it was when the route was opened.
Move a stop and move it back, and the tree is as it was, so Save
turns off again.
save calls commit once. The form hands the
model the whole tree as stops_attributes, each stop with
its cargos_attributes, and a stop's position is its place
in the array. The model saves it in one transaction. A save the models
turn down leaves each message on the form it belongs to, so it
appears under its own row. Because nothing is written until Save,
Cancel needs no undo: it closes the route, and the next Edit fills
the form from the table again.
Naming a row
<%# The collection: where Add a stop aims. %>
<%= tag.button "Add a stop", **on(:nested_add, with: { dom:, path: "stops" }) %>
<%# A stop the table has (c3), and one added in this form (n1). %>
<%= tag.button "✕", **on(:nested_remove, with: { dom:, path: "stops/c3" }) %>
<%= tag.button "↑", **on(:nested_move, with: { dom:, path: "stops/n1", to: index - 1 }) %>
<%# A field two levels down. Its name is its path, so no two fields share one. %>
<%= tag.input name: "stops/c3/cargos/n1/goods", value: cargo.goods,
**on(:nested_set_field, event: :input,
with: { dom:, path: "stops/c3/cargos/n1", field: "goods" }) %>
A control says which row it means with a path: association
names and row keys in turn, such as stops/c3/cargos/n1.
A key is c and the row's
id for a row the table has, and n and a counter for a row
added in this form. The actions walk the path from the top form and
check each step against what the forms declare. A path with an
association no form declared, a key that is not in the list, or a
field the row's form does not have is dropped. The declared forms are
the allowlist, as strong parameters are in a controller.
A row's DOM id is made from its path, so a morph after a move finds every row again and leaves its fields alone, with whatever is typed in them.
What a render follows
Hibiki::Effect.new do
locals = { rows: rows.value, editing_id: @editing_id.value, form: @form, notice: @notice.value }
# What a render follows in the open form: its rows and their order,
# whether there is anything to save, and the messages of a refused save.
[ @form.shape, @form.dirty?, @form.problems ] if @editing_id.value
# What it only reads: every field, at every depth.
Hibiki.untrack do
broadcast_morph target: "nested_routes", partial: "#{VIEWS}/routes", locals:
end
end
# The days are text in one place, so they are sent as text.
transmit_value(:nested_days) { @form.days }
The routes are one fragment, morphed by one effect. If that effect
read every field, each keystroke would render the whole form again.
So the form has a derived, a value computed from other
states and computed again when one of them changes. Here it is called
shape: the keys of the rows, in order, with their marks
to go, and the shape of each stop's cargo in turn. It changes on add,
remove and move, and never on a keystroke. The effect reads
shape, dirty? and the messages of a refused
save, and reads the fields inside Hibiki.untrack, so
typing renders nothing.
Typing still writes each field to its state. That is why a render
after a move draws every field with what was typed in it, and why the
days can be added up: days is a derived over the stops'
days fields, and transmit_value sends it to the page as
text whenever it changes.
Dragging
dropped({ item, oldIndex, newIndex }) {
if (newIndex === oldIndex) return
const accepted = performOn(this.element, "nested_move",
{ dom: this.domValue, path: item.dataset.path, to: newIndex })
// Sent: a render is coming, and it will agree with the drop.
if (accepted) return
// Not sent: the island is offline. Put the row back.
const others = [...this.element.children].filter((row) => row !== item)
this.element.insertBefore(item, others[oldIndex] ?? null)
}
A drag has to follow the pointer, so it cannot wait for a round trip.
SortableJS does
the dragging in the browser, and a small Stimulus controller does the
one thing left: when the row is dropped, it tells the island where the
row went. performOn calls nested_move with
the row's path and its new index, the action the arrows call.
performOn says whether the move was sent. When it was, a
render is on its way, and it will agree with the drop, so the morph
changes only the arrows that are now at an end of the list. When it
was not, the island is offline and no render is coming, so the
controller puts the row back where it was. Any gesture that markup cannot
describe can be wired this way: a library handles the gesture, and
the island hears the result.
The same feature in stock Rails
Stock Rails has nested forms of its own. fields_for and
accepts_nested_attributes_for draw and save the rows that
exist when the page loads. Adding, removing and ordering rows before
Save is left to JavaScript, and so is everything on the page that
depends on the rows: the days, whether Save has anything to save,
whether Add has room. Here is a sketch. It is not run here.
class RoutesController < ApplicationController
before_action :set_route, only: %i[show edit update]
def index
@routes = Route.includes(stops: :cargos)
end
# The closed card, for Cancel.
def show
end
def edit
end
def update
if @route.update(route_params)
render :show
else
# The rows the browser added come back as unsaved records, and the
# form has to draw them again with their messages.
render :edit, status: :unprocessable_entity
end
end
private
def set_route
@route = Route.find(params[:id])
end
# Every level names its own attributes, its `id` and its `_destroy`.
# `position` is a hidden field the browser keeps in order.
def route_params
params.expect(route: [
:name,
stops_attributes: [ [
:id, :place, :days, :position, :_destroy,
cargos_attributes: [ [ :id, :goods, :quantity, :_destroy ] ]
] ]
])
end
end
class Route < ApplicationRecord
has_many :stops, -> { order(:position) }, dependent: :destroy
accepts_nested_attributes_for :stops, allow_destroy: true
validates :name, presence: true
# ...the same rules as the Hibiki version.
# Other visitors see a saved route.
after_update_commit -> { broadcast_replace_to "routes", partial: "routes/route" }
end
class Stop < ApplicationRecord
belongs_to :route, touch: true
has_many :cargos, dependent: :delete_all
accepts_nested_attributes_for :cargos, allow_destroy: true
end
class Cargo < ApplicationRecord
belongs_to :stop, touch: true
end
<%= turbo_stream_from "routes" %>
<%= render partial: "route", collection: @routes %>
<%# Also rendered by `show`, and by the model's broadcast. The broadcast
replaces the frame's contents, so it must not reach a frame that is
open as a form: target an element only the closed card has. %>
<%= turbo_frame_tag route do %>
<h3><%= route.name %></h3>
<%= link_to "Edit", edit_route_path(route) %>
<ol>
<% route.stops.each do |stop| %>
<li>
<%= stop.place %>, <%= pluralize stop.days, "day" %>:
<%= stop.cargos.map { "#{it.goods} × #{it.quantity}" }.to_sentence %>
</li>
<% end %>
</ol>
<% end %>
<%= turbo_frame_tag @route do %>
<%= form_with model: @route,
data: { controller: "nested-form", action: "input->nested-form#changed" } do |form| %>
<%= form.text_field :name %>
<%= render "errors", record: @route %>
<ol data-nested-form-target="stops">
<%= form.fields_for :stops do |stop| %>
<%= render "stop_fields", form: stop %>
<% end %>
</ol>
<%# A blank stop for the browser to copy. NEW_STOP stands where
`fields_for` would put the row's number, and the controller writes
a number of its own there. The stop's own template for cargo is
copied along with it, so its index needs a second name. %>
<template data-nested-form-target="stopTemplate">
<%= form.fields_for :stops, Stop.new, child_index: "NEW_STOP" do |stop| %>
<%= render "stop_fields", form: stop %>
<% end %>
</template>
<button type="button" data-action="nested-form#addStop" data-nested-form-target="addStop">Add a stop</button>
<span data-nested-form-target="leaving" hidden></span>
Days on the road: <b data-nested-form-target="days"><%= @route.stops.sum(&:days) %></b>
<%= form.submit "Save", disabled: true, data: { nested_form_target: "save" } %>
<%= link_to "Cancel", @route %>
<% end %>
<% end %>
<li data-nested-form-target="stop">
<%# What the browser cannot do with markup: remember the row, mark it
to go, and say where it is in the order. %>
<%= form.hidden_field :id %>
<%= form.hidden_field :_destroy, data: { nested_form_target: "destroy" } %>
<%= form.hidden_field :position, data: { nested_form_target: "position" } %>
<span data-drag-handle>⠿</span>
<%= form.text_field :place %>
<%= form.number_field :days, data: { nested_form_target: "dayField" } %>
<button type="button" data-action="nested-form#up">↑</button>
<button type="button" data-action="nested-form#down">↓</button>
<button type="button" data-action="nested-form#remove">✕</button>
<%= render "errors", record: form.object %>
<ul data-nested-form-target="cargos">
<%= form.fields_for :cargos do |cargo| %>
<%= render "cargo_fields", form: cargo %>
<% end %>
</ul>
<template data-nested-form-target="cargoTemplate">
<%= form.fields_for :cargos, Cargo.new, child_index: "NEW_CARGO" do |cargo| %>
<%= render "cargo_fields", form: cargo %>
<% end %>
</template>
<button type="button" data-action="nested-form#addCargo">Add cargo</button>
</li>
<li data-nested-form-target="cargo">
<%= form.hidden_field :id %>
<%= form.hidden_field :_destroy, data: { nested_form_target: "destroy" } %>
<%= form.text_field :goods %> × <%= form.number_field :quantity %>
<button type="button" data-action="nested-form#remove">✕</button>
<%= render "errors", record: form.object %>
</li>
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
const MAX_STOPS = 6
const MAX_CARGO = 3
// Everything the form does before Save happens here, since the server
// does not hear of it until then.
export default class extends Controller {
static targets = ["stops", "stop", "stopTemplate", "cargos", "cargoTemplate", "addStop",
"destroy", "position", "dayField", "days", "leaving", "save"]
connect() {
this.sortable = Sortable.create(this.stopsTarget, {
handle: "[data-drag-handle]", animation: 150, onEnd: () => this.changed()
})
this.changed()
}
disconnect() {
this.sortable.destroy()
}
// A new row is a copy of the template with a number nothing else has
// in place of the placeholder, so its fields get names of their own.
addStop() {
const html = this.stopTemplateTarget.innerHTML.replaceAll("NEW_STOP", Date.now())
this.stopsTarget.insertAdjacentHTML("beforeend", html)
this.stopsTarget.lastElementChild.querySelector("input[type=text]").focus()
this.changed()
}
addCargo({ target }) {
const stop = target.closest("[data-nested-form-target=stop]")
const template = stop.querySelector("[data-nested-form-target=cargoTemplate]")
const list = stop.querySelector("[data-nested-form-target=cargos]")
list.insertAdjacentHTML("beforeend", template.innerHTML.replaceAll("NEW_CARGO", Date.now()))
list.lastElementChild.querySelector("input[type=text]").focus()
this.changed()
}
// A saved row is hidden and marked, so the server deletes it. A row
// that was never saved is only taken out.
remove({ target }) {
const row = target.closest("li")
if (row.querySelector("input[name$='[id]']").value) {
row.querySelector("[data-nested-form-target=destroy]").value = "1"
row.hidden = true
} else {
row.remove()
}
this.changed()
}
up({ target }) {
const row = target.closest("li")
this.keptBefore(row)?.before(row)
this.changed()
}
down({ target }) {
const row = target.closest("li")
this.keptAfter(row)?.after(row)
this.changed()
}
// Runs after every change: the page has to be made to agree with itself.
changed() {
const stops = this.keptStops()
// The order is the hidden position fields, written again each time.
stops.forEach((stop, index) => {
stop.querySelector("[data-nested-form-target=position]").value = index
stop.querySelector("[data-action='nested-form#up']").disabled = index === 0
stop.querySelector("[data-action='nested-form#down']").disabled = index === stops.length - 1
stop.querySelector("[data-action='nested-form#addCargo']").disabled = this.keptCargo(stop).length >= MAX_CARGO
})
this.addStopTarget.disabled = stops.length >= MAX_STOPS
// The total, worked out a second time in JavaScript.
this.daysTarget.textContent = stops.reduce((sum, stop) =>
sum + (parseInt(stop.querySelector("[data-nested-form-target=dayField]").value) || 0), 0)
const leaving = this.stopTargets.length - stops.length
this.leavingTarget.hidden = leaving === 0
this.leavingTarget.textContent = `${leaving} ${leaving === 1 ? "stop leaves" : "stops leave"} the route on save.`
// Save only when something changed: compare the fields with how they
// were when the form opened. Moving a row and moving it back counts
// as nothing, since the positions are fields too.
const now = this.serialize()
this.opened ??= now
this.saveTarget.disabled = now === this.opened
}
serialize() {
return new URLSearchParams(new FormData(this.element)).toString()
}
keptStops() {
return this.stopTargets.filter((stop) => !stop.hidden)
}
keptCargo(stop) {
return [...stop.querySelectorAll("[data-nested-form-target=cargo]")].filter((cargo) => !cargo.hidden)
}
keptBefore(row) {
const stops = this.keptStops()
return stops[stops.indexOf(row) - 1]
}
keptAfter(row) {
const stops = this.keptStops()
return stops[stops.indexOf(row) + 1]
}
}
resources :routes, only: %i[index show edit update]
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| Adding a row | A <template> per level, copied by JavaScript with a new index written into every field name |
on(:nested_add) with the list's path |
| Removing a row | A hidden _destroy field set by JavaScript, and the row hidden; a new row is taken out instead |
on(:nested_remove) with the row's path |
| The order | A hidden position field per row, written again by JavaScript after every change |
The order of the array, written to position in to_h |
| Dragging | SortableJS, then the renumbering | SortableJS, then performOn |
| The arrows, and which are at an end | Two more methods, and a pass over the rows | on(:nested_move); the partial knows the index |
| The days, added up | Added up in JavaScript on every input | One derived and one transmit_value |
| Save only when something changed | Compare the form's fields with a copy taken when it opened | form.dirty?, read by the partial |
| The limits on stops and cargo | Written twice: in the model, and as constants in JavaScript | The model's constants, read by the forms |
| A refused save | Render the form again with a 422, and draw the unsaved rows from the records | Nothing; each message is on its row's form |
| A third level | A second template inside the first, with a second placeholder | One reactive_nested line and a partial |
| JavaScript you write | One Stimulus controller of about a hundred lines | One of about thirty, for the drag alone |
| What adding or moving a row costs | Nothing; it happens in the browser | One round trip over the open socket |
| With the socket down | The form still works, and Save is a request | The buttons wait for the socket, and a dragged row goes back |
The stock form is quicker to the touch, since the browser adds a row itself without waiting on the server, and it keeps working with no socket. In return, the browser has to keep a copy of the form's rules. It has to know how a field is named, what marks a row to go and how many rows are allowed, and it has to work out again the days, etc. All of this means more JavaScript. In the Hibiki version each row is a form on the server, and the page is rendered from it, so the browser keeps none of that.
Both versions assume that when two visitors save the same route, 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.