Cross-field Validation
A form for booking a room at the inn. A message under a field can follow another field: pick a bigger room and the note about too many guests disappears. The Save button waits until the form is both valid and changed. The rules are the model's own validations, written once in Ruby.
A room at the inn
Guests of the inn
- Nobody has signed the ledger today.
Nothing to save yet.
# frozen_string_literal: true
# A booking at the inn. The form holds the fields as signals and asks the
# model's validations what is wrong with them. Nothing here names a rule, or
# says which message belongs to which keystroke.
class Recipes::Validation::ValidationChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Booking = Recipes::Validation::Booking
BookingForm = Recipes::Validation::BookingForm
VIEWS = "app/recipes/validation"
def build_graph
@form = BookingForm.from(Booking.start)
@resets = Hibiki::State.new(0)
@saves = Hibiki::State.new(0)
# One line of text under each field, sent only when the text changes. A
# message that depends on two fields follows both.
BookingForm.hibiki_attributes.each do |field|
transmit_value(:"validation_error_#{field}") { @form.error_for(field) }
end
# The deposit tick comes and goes with the length of the stay. `paint`
# reads the tick's own value without subscribing to it.
Hibiki::Effect.new do
@form.nights
paint :deposit
end
# Save, Reset and the line beside them read `ready?` and `dirty?`.
Hibiki::Effect.new do
broadcast_morph target: "validation_actions", partial: "#{VIEWS}/actions", locals: { form: @form }
end
# The ledger is read again after every save, here or in another tab.
# Its lines are plain values, so a save that changes none of the five
# sends nothing.
ledger = Hibiki::Derived.new do
@saves.value
Booking.ledger
end
Hibiki::Effect.new do
broadcast_morph target: "validation_ledger", partial: "#{VIEWS}/ledger",
locals: { entries: ledger.value, mine: @form.record.id }
end
# The fields are the visitor's to change, so the channel leaves them
# alone, except to put them back after Reset.
Hibiki::Effect.new do
next unless @resets.value.positive?
paint :fields
end
end
def set_field(data)
name = data["field"].to_s.to_sym
return unless BookingForm.hibiki_attributes.include?(name)
value = data[name.to_s]
@form.public_send(:"#{name}=", value.is_a?(String) ? value.first(60) : value)
end
def save
return unless @form.ready?
created = !@form.persisted?
return unless @form.commit
@saves.value += 1
Booking.prune! if created
end
# Back to the booking as it was last saved, or to a new one.
def reset
@form.hydrate(saved || Booking.start)
@resets.value += 1
end
private
def subscribed
super
return if subscription_rejected?
# A booking saved in another tab. The block runs on a cable thread, so
# it hops onto the graph thread before touching a signal.
stream_from Booking::CHANGED do
graph_actor&.post { @saves.value += 1 }
end
end
def saved = (Booking.find_by(id: @form.record.id) if @form.persisted?)
# Renders one of the form's partials without subscribing to what it reads.
def paint(name)
Hibiki.untrack do
broadcast_replace target: "validation_#{name}", partial: "#{VIEWS}/#{name}", locals: { form: @form }
end
end
end
# frozen_string_literal: true
# The booking form in the Cross-field Validation recipe. Each field is a
# signal. The form has no rules of its own: it builds a draft booking from
# the fields and asks the model what is wrong with it.
class Recipes::Validation::BookingForm
include Hibiki::Rails::ReactiveForm
Booking = Recipes::Validation::Booking
reactive_attributes "Recipes::Validation::Booking", :name, :arrives_on, :leaves_on, :guests, :room, :deposit
# The fields as a booking that is never saved. No rule reads the database,
# so validating it on every keystroke costs no query.
derived(:draft) { Booking.new(**to_h) }
derived(:live_errors) { draft.tap(&:validate).errors.to_hash.transform_values(&:first) }
derived(:valid?) { live_errors.empty? }
derived(:nights) { draft.nights }
derived(:deposit_needed?) { draft.deposit_needed? }
derived(:deposit_due) { draft.deposit_due }
# What Save is waiting for.
derived(:ready?) { dirty? && valid? }
# An untouched form says nothing about its empty fields.
def error_for(name) = super || (live_errors[name.to_sym] if dirty?)
end
# frozen_string_literal: true
# A booking at the inn in the Cross-field Validation recipe, and every rule
# a booking has to pass. The form asks these same validations on each
# keystroke, so a rule is written once.
#
# The rows are whatever visitors saved, and the newest few are the inn's
# ledger, shown to everyone. Recipes::Reset empties the table each night, and
# `prune!` caps it in between. A tab still open over a reset saves to a row
# that is gone, which changes nothing and is fine for a demo.
class Recipes::Validation::Booking < ApplicationRecord
self.table_name = "recipes_validation_bookings"
Room = Data.define(:name, :beds)
# A line of the ledger: a plain copy of a row, compared by what it says.
Entry = Data.define(:id, :name, :room, :arrives_on, :nights)
ROOMS = {
"hayloft" => Room.new(name: "Hayloft", beds: 1),
"twin" => Room.new(name: "Twin room", beds: 2),
"family" => Room.new(name: "Family room", beds: 4),
"hall" => Room.new(name: "Long hall", beds: 8)
}.freeze
# A stay of more nights than this needs a deposit, at so much a night.
DEPOSIT_AFTER = 3
DEPOSIT_PER_NIGHT = 10
# How many rows `prune!` leaves, and how many of them the ledger shows.
KEEP = 200
LEDGER = 5
# Every open form listens here for a booking saved by someone else.
CHANGED = "recipes:validation:bookings"
after_commit { self.class.announce }
before_validation { self.deposit = false unless deposit_needed? }
validates :name, presence: { message: "The ledger needs a name." },
length: { maximum: 40, message: "Forty letters is all the ledger has room for." }
validates :arrives_on, presence: { message: "Pick the day you arrive." }
validates :leaves_on, presence: { message: "Pick the day you leave." }
validates :room, inclusion: { in: ROOMS.keys, message: "The inn has no such room." }
validates :guests, numericality: { only_integer: true, greater_than: 0, message: "At least one guest." }
validate :leaves_after_arriving, :guests_fit_the_room, :deposit_for_a_long_stay
# What the form holds when the page opens: one night from tomorrow.
def self.start = new(arrives_on: Date.current + 1, leaves_on: Date.current + 2, guests: 1)
# The newest bookings, the latest last, as a ledger is written.
def self.ledger = order(id: :desc).limit(LEDGER).map(&:entry).reverse
# A ping with nothing in it. Whoever hears it reads the ledger again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# `delete_all` runs no callback, so the ping is sent by hand.
def self.reset!
delete_all
announce
end
# Drops all but the newest rows.
def self.prune! = where.not(id: order(id: :desc).limit(KEEP).select(:id)).delete_all
def self.gp(amount) = "#{amount.to_fs(:delimited)} gp"
def nights = arrives_on && leaves_on ? [ (leaves_on - arrives_on).to_i, 0 ].max : 0
def room_type = ROOMS[room]
def entry = Entry.new(id:, name:, room: room_type.name, arrives_on:, nights:)
def deposit_needed? = nights > DEPOSIT_AFTER
def deposit_due = deposit_needed? ? nights * DEPOSIT_PER_NIGHT : 0
private
def leaves_after_arriving
return unless arrives_on && leaves_on
errors.add(:leaves_on, "You have to leave after you arrive.") if leaves_on <= arrives_on
end
def guests_fit_the_room
return unless room_type && guests.to_i > room_type.beds
errors.add(:guests, "The #{room_type.name.downcase} sleeps #{room_type.beds}.")
end
def deposit_for_a_long_stay
errors.add(:deposit, "A stay this long needs the deposit.") if deposit_needed? && !deposit
end
end
<%# The page builds the same form the channel does, from a new booking, so
the first paint and every later one ask the same rules. %>
<% form = Recipes::Validation::BookingForm.from(Recipes::Validation::Booking.start) %>
<%= island Recipes::Validation::ValidationChannel, 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">A room at the inn</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<%= render "app/recipes/validation/ledger", entries: Recipes::Validation::Booking.ledger %>
<%= render "app/recipes/validation/fields", form: %>
<%= render "app/recipes/validation/deposit", form: %>
<%= render "app/recipes/validation/actions", form: %>
<% end %>
<%# locals: (entries:, mine: nil) -%>
<%# The inn's ledger: the last few bookings from every visitor, the latest
at the bottom. Morphed by the channel whenever one is saved. %>
<div id="validation_ledger" class="mt-3 rounded-box border-2 border-base-300 bg-base-200 px-4 pt-2 pb-3">
<p class="border-b-2 border-base-content/40 pb-1 text-center font-serif text-xs font-bold tracking-[0.25em] uppercase opacity-70">
Guests of the inn
</p>
<ol id="validation_entries" class="font-serif text-sm italic">
<% entries.each do |entry| %>
<li id="validation_entry_<%= entry.id %>"
class="grid grid-cols-[1fr_auto] gap-x-3 border-b border-dashed border-base-content/30 py-1 sm:grid-cols-[1fr_7rem_9rem]">
<span class="truncate">
<%= entry.name %>
<% if entry.id == mine %><b class="ml-1 text-xs text-primary not-italic">you</b><% end %>
</span>
<span class="hidden opacity-80 sm:block"><%= entry.room %></span>
<span class="text-right whitespace-nowrap tabular-nums opacity-80">
<%= entry.arrives_on.strftime("%-d %b") %>, <%= pluralize(entry.nights, "night") %>
</span>
</li>
<% end %>
<% (Recipes::Validation::Booking::LEDGER - entries.size).times do |line| %>
<li id="validation_blank_<%= line %>" class="border-b border-dashed border-base-content/30 py-1 opacity-50">
<%= line.zero? && entries.empty? ? "Nobody has signed the ledger today." : sanitize(" ") %>
</li>
<% end %>
</ol>
</div>
<%# locals: (form:) -%>
<%# Rendered with the page, and again only after Reset, so typing is never
interrupted. Under each field is a placeholder the channel writes that
field's message into. Enter saves. %>
<% field = ->(name) { on(:set_field, event: name.in?(%i[name guests]) ? :input : :change, with: { field: name }) } %>
<% error = ->(name) { tag.p(reactive(:"validation_error_#{name}", form.error_for(name)), class: "min-h-5 text-sm text-error") } %>
<%= tag.form id: "validation_fields", class: "mt-3 grid gap-x-3 gap-y-1 sm:grid-cols-2",
**on(:save, event: :submit, reset: false) do %>
<label class="flex flex-col gap-1 sm:col-span-2">
<span class="text-sm font-semibold">Name for the ledger</span>
<%= tag.input type: "text", name: "name", value: form.name, maxlength: 60, autocomplete: "off",
class: "input w-full", **field.(:name) %>
<%= error.(:name) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Arrive</span>
<%= tag.input type: "date", name: "arrives_on", value: form.arrives_on, class: "input w-full", **field.(:arrives_on) %>
<%= error.(:arrives_on) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Leave</span>
<%= tag.input type: "date", name: "leaves_on", value: form.leaves_on, class: "input w-full", **field.(:leaves_on) %>
<%= error.(:leaves_on) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Room</span>
<%= tag.select name: "room", class: "select w-full", **field.(:room) do %>
<%= options_for_select Recipes::Validation::Booking::ROOMS.map { |key, room| [ "#{room.name}, sleeps #{room.beds}", key ] }, form.room %>
<% end %>
<%= error.(:room) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Guests</span>
<%= tag.input type: "number", name: "guests", value: form.guests, min: 1, inputmode: "numeric",
class: "input w-full tabular-nums", **field.(:guests) %>
<%= error.(:guests) %>
</label>
<% end %>
<%# locals: (form:) -%>
<%# Replaced by the channel when the number of nights changes. A short stay
leaves only the empty wrapper. %>
<div id="validation_deposit">
<% if form.deposit_needed? %>
<div class="mt-2 rounded-box border-2 border-base-300 bg-base-200 p-3">
<label class="flex items-start gap-3">
<%= tag.input type: "checkbox", name: "deposit", checked: form.deposit, class: "checkbox checkbox-primary checkbox-sm mt-0.5",
**on(:set_field, event: :change, with: { field: :deposit }) %>
<span class="text-sm">
<%= pluralize(form.nights, "night") %> is a long stay. I will leave a deposit of
<b><%= Recipes::Validation::Booking.gp(form.deposit_due) %></b>.
</span>
</label>
<p class="min-h-5 text-sm text-error"><%= reactive :validation_error_deposit, form.error_for(:deposit) %></p>
</div>
<% end %>
</div>
<%# locals: (form:) -%>
<%# Morphed by the channel. Save waits for a form that is both changed and
valid; the rest follows from whether it is changed and whether it has
been saved. %>
<div id="validation_actions" class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 border-t-2 border-base-300 pt-3">
<%= tag.button form.persisted? ? "Save changes" : "Book the room", id: "validation_save", type: "button",
class: "btn btn-primary btn-sm", disabled: !form.ready?, **on(:save) %>
<% if form.dirty? %>
<%= tag.button "Reset", id: "validation_reset", type: "button", class: "btn btn-sm", **on(:reset) %>
<p id="validation_note" class="text-sm opacity-70">
<%= form.valid? ? "You have unsaved changes." : "Not ready to save yet." %>
</p>
<% elsif form.persisted? %>
<% room = Recipes::Validation::Booking::ROOMS.fetch(form.room).name.downcase %>
<p id="validation_note" class="text-sm">Booked: <%= pluralize(form.nights, "night") %> in the <%= room %> for <%= form.name %>.</p>
<% else %>
<p id="validation_note" class="text-sm opacity-70">Nothing to save yet.</p>
<% end %>
</div>
The form above books a room at the inn: a name, the days you arrive and leave, a room and how many guests. Type 3 into the guests field, and you can see a message appear under it, since the twin room sleeps only two. Pick the family room in the select and the message goes, though you never touched the guests. Stay more than three nights and a deposit tick appears. The Save button enables only once the form is both valid and different from what was last saved. Keeping messages like these in step is tedious and often creates intractably complex code in stock Rails. You may be tempted to do these in JavaScript, which means the rules are then written twice. Here the rules are the model's own validations, written once in Ruby, and each message follows whichever fields its rule reads.
How it fits together
The booking 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 the states
are the fields of a reactive form, BookingForm.
One line, reactive_attributes, names the booking's columns
and makes a state of each. The channel has one action for the fields,
set_field, which writes the one that changed.
A derived is a value worked out from other values, and worked
out again when one of them changes. Here draft is a derived
that builds a booking from every field, one that is never saved, and
live_errors is a derived that asks the draft what is wrong
with it. valid? and nights follow from those.
An effect is a block of code that runs once, and runs again
whenever a value it read has changed. The message under each field is
sent by transmit_value, an effect that sends a piece of
text, and sends it only when the text differs from last time. One more
effect morphs the buttons and the line beside them, and another replaces
the deposit tick. This is what happens when you type 3 into the guests
field:
-
The field calls
set_field, which writesguestsand returns. -
draftreadguests, so a new draft is built, andlive_errorsread the draft, so the validations run again. The guests message now says the twin room sleeps 2. -
The
transmit_valuefor the guests field read that message, so it sends the new text. The other messages come out as they were, so nothing is sent for them. -
The buttons effect read
dirty?, which is now true, so it sends the buttons again, with Reset beside them and a note that the form is not ready.
Now pick the family room. The draft is built again, the guests message
comes out empty, and the same transmit_value sends the
empty text. The channel never says that the guests message depends on
the room. Whatever a block read on its last run is what it depends on,
and the draft reads every field.
The rules are written once
The rules are ordinary validations on the model, the same ones that guard a save. The form has no rules of its own: it builds a draft from its fields and asks the model what is wrong with it.
# app/models/recipes/validation/booking.rb
validates :name, presence: { message: "The ledger needs a name." }
validate :leaves_after_arriving, :guests_fit_the_room, :deposit_for_a_long_stay
def guests_fit_the_room
return unless room_type && guests.to_i > room_type.beds
errors.add(:guests, "The #{room_type.name.downcase} sleeps #{room_type.beds}.")
end
# app/forms/recipes/validation/booking_form.rb
derived(:draft) { Booking.new(**to_h) } # reads every field
derived(:live_errors) { draft.tap(&:validate).errors.to_hash.transform_values(&:first) }
# app/channels/recipes/validation/validation_channel.rb
transmit_value(:validation_error_guests) { @form.error_for(:guests) }
guests_fit_the_room reads the guests and the room, so the
message under the guests field follows both. A rule added to the model
tomorrow shows up under its field with no other edit. Note that no rule
here reads the database, so validating the draft on every keystroke
costs no query. A rule that does, such as a uniqueness check, is better
left to the save: a failed save copies the model's errors into the form,
and error_for shows those first.
Save knows whether anything changed
A Save button that is always enabled leaves the visitor to remember
whether they changed anything. Here the form remembers for them. A
reactive form keeps a snapshot of the fields as they were last loaded or
saved, and dirty? is a derived that compares the fields
with it. Type a name and delete it again, and the form is clean once
more.
# ReactiveForm keeps a snapshot of the fields as they were last loaded or
# saved. `dirty?` is a derived that compares the fields with it.
derived(:valid?) { live_errors.empty? }
derived(:ready?) { dirty? && valid? }
# An untouched form says nothing about its empty fields.
def error_for(name) = super || (live_errors[name.to_sym] if dirty?)
# The channel. `commit` runs the model's validations once more, saves, and
# takes a new snapshot, so `dirty?` is false again.
def save
return unless @form.ready?
created = !@form.persisted?
Booking.prune! if @form.commit && created
end
ready? is what the button waits for: changed and valid. An
untouched form says nothing about its empty fields, because
error_for holds a message back until the form is dirty.
commit runs the validations once more, saves, and takes a
new snapshot, so the button then reads Save changes and waits for the
next valid change. Reset loads the saved booking back into the form, or
a new one, and sends the fields once more.
A field that comes and goes
The deposit tick is on the page only for a stay of more than three nights, so it is a fragment of its own. Its effect subscribes to the number of nights alone, and ticking the box does not send the box back:
Hibiki::Effect.new do
@form.nights # subscribes: a different length of stay, a new fragment
paint :deposit # does not: ticking the box sends nothing here
end
def paint(name)
Hibiki.untrack do
broadcast_replace target: "validation_#{name}", partial: "#{VIEWS}/#{name}", locals: { form: @form }
end
end
paint renders the fragment inside Hibiki.untrack,
which reads the form without subscribing to it. So the effect runs when
the stay changes length and stays quiet when the tick itself changes.
The message under the tick is a transmit_value like the
others.
A ledger every visitor sees
Save writes a real row, and the newest five are the ledger at the top of the panel, shown to everyone who has the page open. The model pings after each save, each channel adds one to a counter, and a derived that read the counter reads the five rows again. Your own line is marked, since each tab renders the ledger for itself. The table is emptied every night.
The same feature in stock Rails
The usual stock Rails build that keeps the rules in Ruby posts the whole form to a validate action on every change and answers with Turbo Streams. Here is a sketch of it. It is not run here, and it leaves out the validations and the three 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 BookingsController < ApplicationController
before_action :set_booking
def new
end
def edit
end
# Every change that settles posts the whole form here. Nothing is saved:
# the booking is filled in, validated, and its messages sent back.
def validate
@booking.assign_attributes(booking_params)
@booking.validate
# The server can tell "changed" for a saved booking. It cannot tell
# which fields the visitor has touched, so the form sends that too,
# or every empty field is marked wrong on the first keystroke.
@touched = params[:touched].to_s.split(",")
respond_to { |format| format.turbo_stream }
end
def create
@booking.assign_attributes(booking_params)
if @booking.save
redirect_to edit_booking_path(@booking), notice: "Booked."
else
render :new, status: :unprocessable_entity
end
end
def update
if @booking.update(booking_params)
redirect_to edit_booking_path(@booking), notice: "Saved."
else
render :edit, status: :unprocessable_entity
end
end
private
def set_booking = @booking = params[:id] ? Booking.find(params[:id]) : Booking.start
def booking_params = params.expect(booking: %i[name arrives_on leaves_on guests room deposit])
end
<%# The list of everything a keystroke can move, kept by hand. The fields
must not be replaced while someone types in them, so each message gets
an id and a line of its own. A new field is a new line here. %>
<% %i[name arrives_on leaves_on guests room].each do |field| %>
<%= turbo_stream.update "booking_#{field}_error",
(@booking.errors[field].first if @touched.any?) %>
<% end %>
<%# The deposit tick comes and goes. Replacing it on every keystroke would
lose a tick made while the request was in flight, so it is only sent
when the form says the length of the stay changed. %>
<% if params[:nights_changed] == "1" %>
<%= turbo_stream.replace "booking_deposit", partial: "bookings/deposit", locals: { booking: @booking } %>
<% else %>
<%= turbo_stream.update "booking_deposit_error", @booking.errors[:deposit].first %>
<% end %>
<%# The button holds no field, so it can be replaced whole. Whether the
form has changed is for the Stimulus controller to say: it disables
the button again after this arrives. %>
<%= turbo_stream.replace "booking_actions", partial: "bookings/actions",
locals: { booking: @booking, valid: @booking.errors.empty? } %>
<%# The ledger listens on a stream of its own. Below it, one form around
everything else. The Stimulus controller posts it to
`validate` after each change, and to create or update on Save. %>
<%= turbo_stream_from "bookings" %>
<%= render "bookings/ledger", bookings: Booking.ledger %>
<%= form_with model: @booking,
data: { controller: "booking-form",
booking_form_validate_url_value: validate_bookings_path(id: @booking.id),
action: "input->booking-form#changed change->booking-form#changed" } do |form| %>
<%= hidden_field_tag :touched, "", data: { booking_form_target: "touched" } %>
<%= hidden_field_tag :nights_changed, "0", data: { booking_form_target: "nightsChanged" } %>
<%= form.label :name %>
<%= form.text_field :name %>
<p id="booking_name_error"><%= @booking.errors[:name].first %></p>
<%= form.label :arrives_on %>
<%= form.date_field :arrives_on %>
<p id="booking_arrives_on_error"><%= @booking.errors[:arrives_on].first %></p>
<%= form.label :leaves_on %>
<%= form.date_field :leaves_on %>
<p id="booking_leaves_on_error"><%= @booking.errors[:leaves_on].first %></p>
<%= form.label :room %>
<%= form.select :room, Booking::ROOMS.map { |key, room| [ room.name, key ] } %>
<p id="booking_room_error"><%= @booking.errors[:room].first %></p>
<%= form.label :guests %>
<%= form.number_field :guests, min: 1 %>
<p id="booking_guests_error"><%= @booking.errors[:guests].first %></p>
<%= render "bookings/deposit", booking: @booking %>
<%= render "bookings/actions", booking: @booking, valid: true %>
<% end %>
import { Controller } from "@hotwired/stimulus"
// Three jobs the server cannot do alone: wait for typing to settle, know
// whether the form differs from what was loaded, and remember which fields
// have been touched.
export default class extends Controller {
static targets = ["touched", "nightsChanged", "save", "unsaved"]
static values = { validateUrl: String, wait: { type: Number, default: 250 } }
connect() {
this.snapshot = this.serialize()
this.touched = new Set()
this.nights = this.stay()
// The button arrives enabled or not by validity alone; "changed" is
// put back on top of every reply.
this.afterStream = () => this.showDirty()
document.addEventListener("turbo:before-stream-render", this.afterStream)
this.showDirty()
}
disconnect() {
clearTimeout(this.timer)
document.removeEventListener("turbo:before-stream-render", this.afterStream)
}
changed(event) {
this.touched.add(event.target.name)
this.showDirty()
clearTimeout(this.timer)
this.timer = setTimeout(() => this.validate(), this.waitValue)
}
// Posts the whole form to the validate action. Turbo applies the streams
// that come back. Two posts can be in flight at once, and the slower one
// may answer last with older messages; guarding against that is more
// code, left out here.
validate() {
this.touchedTarget.value = [...this.touched].join(",")
this.nightsChangedTarget.value = this.stay() === this.nights ? "0" : "1"
this.nights = this.stay()
fetch(this.validateUrlValue, {
method: "POST",
body: new FormData(this.element),
headers: { Accept: "text/vnd.turbo-stream.html", "X-CSRF-Token": this.csrfToken }
})
.then((response) => response.text())
.then((html) => Turbo.renderStreamMessage(html))
}
reset() {
this.element.reset() // back to the values the page loaded with
this.touched.clear()
this.validate()
}
showDirty() {
requestAnimationFrame(() => {
const dirty = this.serialize() !== this.snapshot
if (!dirty) this.saveTarget.disabled = true
this.unsavedTarget.hidden = !dirty
})
}
// The length of the stay, worked out a second time, in JavaScript, only
// to know when to ask for the deposit fragment.
stay() {
const { arrives_on, leaves_on } = Object.fromEntries(new FormData(this.element))
return Math.round((new Date(leaves_on) - new Date(arrives_on)) / 86_400_000)
}
serialize() {
return new URLSearchParams(new FormData(this.element)).toString()
}
get csrfToken() {
return document.querySelector("meta[name=csrf-token]").content
}
}
# A sketch of the stock Rails version. It is not part of this app.
# The validations are the same in both versions and are left out.
class Booking < ApplicationRecord
# The ledger, sent to every open page after a save. It is rendered once,
# here, for everyone, so it cannot mark the reader's own line: a model
# has no idea who is looking. That needs a per-visitor stream, or a
# Stimulus controller that finds the line by an id it kept.
after_commit -> { self.class.broadcast_ledger }
def self.ledger = order(id: :desc).limit(5).reverse
# `delete_all` runs no callback, so the nightly reset calls this itself.
def self.broadcast_ledger
Turbo::StreamsChannel.broadcast_replace_to "bookings", target: "booking_ledger",
partial: "bookings/ledger", locals: { bookings: ledger }
end
end
resources :bookings, only: %i[new create edit update] do
post :validate, on: :collection
end
The model is as short as before. What grows is everything around it. The stream template keeps a list of every message a keystroke can move, and a Stimulus controller takes on the three things a request cannot know: when typing has settled, whether the form has changed, and which fields have been touched. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| A message under a field as you type | An id on each message, and a Turbo Stream line for each | One transmit_value for each field |
| A message that depends on another field | Send every message on every change, since the server cannot tell which moved | Nothing; a message is sent when its text changes |
| Save only when valid and changed | Valid from the server, changed from a snapshot kept in JavaScript, joined after each reply | dirty? && valid? |
| An unsaved changes note | The same JavaScript snapshot, and a target to show and hide | A line in the partial that reads dirty? |
| No messages on an untouched form | Track touched fields in JavaScript and post them along | One line in the form's error_for |
| A field that comes and goes | The length of the stay worked out in JavaScript as well, to know when to ask for it | An effect that reads nights |
| What a change sends to the server | Every field of the form | The one value that changed |
| Waiting for typing to settle | A timer in the Stimulus controller | on(:set_field, event: :input) |
| 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 |
| A ledger every visitor sees | A stream on the page and a broadcast in the model, which renders the partial itself | A ping in the model, and a derived and an effect in the channel |
| Marking your own line in it | Not from a model broadcast, which renders once for everyone; a stream for each visitor, or more JavaScript | A local passed to the partial; each tab renders its own |
| A new rule on the model | The validation, plus a message and a stream line if it lands on a new field | The validation |
Stock Rails has a second way with no round trip: check the fields in a Stimulus controller. It is quick to the eye. However, each rule is then written twice, in Ruby for the booking that is saved and in JavaScript for the message that is shown, and the day one copy changes without the other, the form passes a booking the model turns away. Here the rules stay on the model, and Hibiki carries their messages to the page.