Inline Edit
A quest log where a click turns a row into a form, in place. Hitting the Enter key saves, and the Escape key cancels. A save updates for everyone who has the page open, without closing a row they are editing. The page needs no edit route and no JavaScript of its own.
Quest log
Shared with every visitor. Put back each night.
# frozen_string_literal: true
# The quest log. Which row is open is one state, and belongs to this tab.
# The quests belong to everyone: a save here or in another tab bumps
# `@version`, and the log is read again.
class Recipes::Inline::InlineChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Quest = Recipes::Inline::Quest
QuestForm = Recipes::Inline::QuestForm
VIEWS = "app/recipes/inline"
def build_graph
@version = Hibiki::State.new(0)
@editing_id = Hibiki::State.new(nil)
# One form serves every row; `edit` fills it from the quest.
@form = QuestForm.new
# The rows are plain values, so a save that changes nothing sends nothing.
rows = Hibiki::Derived.new do
@version.value
Quest.rows
end
# The whole log is one fragment. It is rendered again when the quests
# change, when a row opens or closes, and when a save is turned down.
# The open row's 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 }
@form.errors
Hibiki.untrack do
broadcast_morph target: "inline_rows", partial: "#{VIEWS}/rows", locals:
end
end
end
def edit(data)
quest = Quest.find_by(id: data["id"])
return unless quest
@form.hydrate(quest)
@editing_id.value = quest.id
end
def cancel(_data) = @editing_id.value = nil
# Keeps the form up to date with the fields, so a render caused by
# someone else's save puts back what was typed.
def set_field(data)
return unless @editing_id.value
assign(data)
end
def save(data)
return unless @editing_id.value
assign(data)
# A save the model turns down leaves its messages on the form, and the
# row stays open.
return unless @form.commit
@editing_id.value = nil
@version.value += 1
end
private
def subscribed
super
return if subscription_rejected?
# A quest saved in another tab. The block runs on a cable thread, so it
# hops onto the graph thread before touching a signal.
stream_from Quest::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
# Only the form's own attributes are taken from what the browser sent.
def assign(data)
QuestForm.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 row of the Inline Edit recipe. Each field is a signal.
# It has no rules: a save that the model turns down leaves the model's
# messages in `errors`.
class Recipes::Inline::QuestForm
include Hibiki::Rails::ReactiveForm
reactive_attributes "Recipes::Inline::Quest", :name, :reward
end
# frozen_string_literal: true
# A quest in the log of the Inline Edit recipe. There is one log, and every
# visitor edits it, so what a visitor types is public. Recipes::Reset puts
# the names and rewards back each night.
class Recipes::Inline::Quest < ApplicationRecord
self.table_name = "recipes_inline_quests"
# A line of the log: a plain copy of a row, compared by what it says.
Row = Data.define(:id, :name, :reward)
MAX_REWARD = 100_000
SEED_FILE = Rails.root.join("db/seeds/quests.yml")
# Every open log listens here for a quest saved by someone else.
CHANGED = "recipes:inline:quests"
after_commit { self.class.announce }
validates :name, presence: { message: "A quest needs a name." },
length: { maximum: 40, message: "Forty letters is all the log has room for." }
validates :reward, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: MAX_REWARD,
message: "A reward is 0 to #{MAX_REWARD.to_fs(:delimited)} gp." }
def self.rows = order(:id).map(&:row)
# A ping with nothing in it. Whoever hears it reads the log again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# Loads the log from db/seeds/quests.yml, over whatever is there. Safe to
# run again.
def self.seed!
upsert_all(YAML.load_file(SEED_FILE), unique_by: :slug)
end
# `upsert_all` runs no callback, so the ping is sent by hand.
def self.reset!
seed!
announce
end
def row = Row.new(id:, name:, reward:)
end
<%# The page reads the same rows the channel does. No row is open yet. %>
<%= island Recipes::Inline::InlineChannel, 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">
Quest log
<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/inline/rows", rows: Recipes::Inline::Quest.rows %>
<% end %>
<%# locals: (rows:, editing_id: nil, form: nil) -%>
<%# Morphed by the channel. Each row keeps its id whether it is open or not,
so the rows that did not change are left as they are. %>
<div id="inline_rows" class="mt-3">
<ul id="inline_list">
<% rows.each do |row| %>
<li id="inline_row_<%= row.id %>" class="border-b-2 border-base-300">
<% if row.id == editing_id %>
<%= render "app/recipes/inline/form", form: %>
<% else %>
<%= render "app/recipes/inline/row", row: %>
<% end %>
</li>
<% end %>
</ul>
</div>
<%# locals: (row:) -%>
<%# A closed row is one button, so the keyboard can open it too. %>
<%= tag.button type: "button", id: "inline_show_#{row.id}",
class: "group flex w-full cursor-pointer items-center gap-3 px-2 py-2 text-left hover:bg-base-200",
**on(:edit, with: { id: row.id }) do %>
<span class="min-w-0 flex-1 font-semibold"><%= row.name %></span>
<span class="text-xs opacity-0 group-hover:opacity-60 group-focus-visible:opacity-60">edit</span>
<b class="shrink-0 text-right text-sm whitespace-nowrap tabular-nums sm:w-24"><%= Recipes::Totals::Order.gp(row.reward) %></b>
<% end %>
<%# locals: (form:) -%>
<%# An open row. Enter saves, since the fields are in a form. The fields
keep their values when a save is turned down (`reset: false`), and the
model's messages go under them. `focus` puts the cursor in the name when
this tab opens the row. One element takes one action, so Escape goes on
a wrapper around the form. %>
<% id = form.record.id %>
<% field = on(:set_field, event: :input) %>
<%= tag.div id: "inline_edit_#{id}", class: "bg-base-200 px-2 py-2", **on(:cancel, event: "keydown.esc") do %>
<%= tag.form id: "inline_form_#{id}", **on(:save, event: :submit, reset: false) do %>
<div class="flex flex-wrap items-center gap-2">
<%= tag.input type: "text", name: "name", id: "inline_#{id}_name", value: form.name, maxlength: 60,
autocomplete: "off", aria: { label: "Name" }, class: "input input-sm min-w-0 flex-1 basis-48 text-sm",
**field.deep_merge(focus(select: true)) %>
<label class="input input-sm w-32">
<%= tag.input type: "number", name: "reward", id: "inline_#{id}_reward", value: form.reward, min: 0,
inputmode: "numeric", aria: { label: "Reward" }, class: "text-sm tabular-nums", **field %>
<span class="opacity-70">gp</span>
</label>
<%= tag.button "Save", type: "submit", class: "btn btn-primary btn-sm" %>
<%= tag.button "Cancel", type: "button", class: "btn btn-sm", **on(:cancel) %>
</div>
<% form.errors.each_value do |messages| %>
<p class="mt-1 text-sm text-error"><%= messages.first %></p>
<% end %>
<% end %>
<% end %>
The panel above is a quest log: eight quests, each with a name and a reward. Click any quest, and the row now turns into a form, in place, with auto-focus in the name. Pressing Enter submits the form and updates the row. Press Escape while editing, and the row goes back. Open the page in a second window and save a quest there: the line changes in both windows, and a row you have open in the first stays open, with what you had typed still in it. Editing in place usually takes an edit route, a template for the form and JavaScript to open and close the rows. Here which row is open is one value on the server, and the log is drawn from it.
The quest log is shared by every visitor, and anyone can edit it. The log is reset every night.
How it fits together
The log lives in a channel: the server side of an Action Cable
connection, an object that stays alive while the page is open, one for
each tab. A state holds a value and remembers who read it. Here
@editing_id is a state holding the id of the open row, or
nil when no row is open. The channel's public methods are
its actions, the methods the page may call, and three of them
write this state. The open row's fields are a reactive form,
QuestForm. One line,
reactive_attributes, names the quest's columns and makes a
state of each.
The quests themselves are rows in a table that every tab shares. A
derived is a value worked out from other values, and worked out
again when one of them changes. Here rows is a derived that
reads the table and returns a plain copy of each quest. It also reads a
second state, @version, a number that goes up whenever a
quest is saved, in this tab or any other.
An effect is a block of code that runs once, and runs again
whenever a value it read has changed. There is one. It renders the whole
log, with a row drawn as a form when its id is @editing_id
and as a line of text when it is not, and sends it to the page, where it
is morphed into place. This is what happens when you click The Cabbage
Thief:
-
The row's button calls
editwith the quest's id.editfills the form from the quest and writes the id to@editing_id. -
The effect read
@editing_id, so it renders the log again. The Cabbage Thief comes out as a form, and the other rows as they were. - The page morphs the log, leaving the rows that did not change alone, and puts the cursor in the name.
Press Enter, and save writes the quest, then writes
nil to @editing_id and adds one to
@version, so the effect runs once more with the row closed
and the log read again. The channel never says that the log depends on
the open row. Whatever the effect read on its last run is what it
depends on.
The open row is a state
Opening, cancelling and saving a row are usually three different things: a request for a form, a link back, and a post. Here they are three actions that write the same state, and none of them says what to draw:
# Which row is open. It is this tab's alone.
@editing_id = Hibiki::State.new(nil)
def edit(data)
quest = Quest.find_by(id: data["id"])
return unless quest
@form.hydrate(quest)
@editing_id.value = quest.id
end
def cancel(_data) = @editing_id.value = nil
def save(data)
return unless @editing_id.value
assign(data)
return unless @form.commit
@editing_id.value = nil
@version.value += 1
end
save takes what the browser sent into the form and asks it
to commit, which runs the quest's own validations and writes
the row. When a validation fails, commit returns false and
leaves the model's messages in the form's errors. The
effect read those, so it runs again and draws the row still open, with
the messages under the fields and what you typed still in them. The row
closes only after a save that worked.
The quest log is shared; your open row is not
The quests are shared and the open row is not. A quest saved in another
tab has to reach this one, and it must not close the row you are
editing. So the model pings after every save, with nothing in the ping,
and each channel that hears it adds one to @version:
# The model. A ping with nothing in it, after every save.
CHANGED = "recipes:inline:quests"
after_commit { self.class.announce }
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# The channel. Each tab hears the ping and reads the log again.
def subscribed
super
return if subscription_rejected?
stream_from Quest::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
rows = Hibiki::Derived.new do
@version.value
Quest.rows
end
rows read @version, so it reads the table
again, and the effect draws the log with this tab's own
@editing_id. Another visitor's save changes the lines
around the row you have open and leaves yours a form.
That render has to bring your typing back too. Each keystroke in the
open row calls set_field, which writes the field's state in
the form, and the effect draws the fields from the form. So when someone
else's save draws the log again, your row comes back holding what you
had typed. Typing itself draws nothing, because the effect reads the
fields inside Hibiki.untrack, which reads a value without
subscribing to it:
Hibiki::Effect.new do
# What a render follows: the quests, the open row, a save turned down.
locals = { rows: rows.value, editing_id: @editing_id.value, form: @form }
@form.errors
# What it only reads: the fields of the open row.
Hibiki.untrack do
broadcast_morph target: "inline_rows", partial: "#{VIEWS}/rows", locals:
end
end
A keystroke waits a quarter of a second before it is sent. If someone else's save lands inside that wait, the client keeps the field as you left it, since a field with unsent typing wins over a render.
The cursor and the Escape key
A form that opens in place has three small things to get right, each usually a few lines of JavaScript: the cursor should land in the field, Escape should close the form, and the cursor should have somewhere to go when it does. Here the template says what it wants:
<%# Escape, heard anywhere inside the open row. %>
<%= tag.div id: "inline_edit_#{id}", **on(:cancel, event: "keydown.esc") do %>
<%# Enter. The browser submits the form. %>
<%= tag.form id: "inline_form_#{id}", **on(:save, event: :submit, reset: false) do %>
<%# The cursor, and the text selected, when this tab opens the row. %>
<%= tag.input type: "text", name: "name", id: "inline_#{id}_name", value: form.name,
**on(:set_field, event: :input).deep_merge(focus(select: true)) %>
<% end %>
<% end %>
The name field carries focus(select: true). When a render
this tab asked for brings a field with that mark, the client puts the
cursor in it and selects the text, so typing replaces the name. Another
visitor's save draws the same form again and moves no cursor. Escape is
an event like a click: on(:cancel, event: "keydown.esc")
calls cancel when the key is pressed anywhere inside the
element that carries it. That element is a wrapper around the form,
since the form already takes submit and one element takes
one action. Enter needs nothing: the fields are in a form,
and the browser submits it.
When the row closes, the form that held the cursor is gone. The client remembers which button opened it and puts the cursor back there, so a keyboard user keeps their place in the log.
The same feature in stock Rails
The usual stock Rails build makes each row a Turbo Frame. A link in the frame fetches the form, the reply to a save puts the closed row back, and a broadcast from the model tells the other visitors. Here is a sketch of it. It is not run here.
# A sketch of the stock Rails version. It is not part of this app.
class QuestsController < ApplicationController
before_action :set_quest, only: %i[show edit update]
def index
@quests = Quest.order(:id)
end
# Cancel. The row's frame asks for the closed row again.
def show
render partial: "quests/quest", locals: { quest: @quest }
end
# Opening a row. The frame asks for the form.
def edit
end
def update
if @quest.update(quest_params)
render partial: "quests/quest", locals: { quest: @quest }
else
render :edit, status: :unprocessable_entity
end
end
private
def set_quest = @quest = Quest.find(params[:id])
def quest_params = params.expect(quest: %i[name reward])
end
class Quest < ApplicationRecord
validates :name, presence: true, length: { maximum: 40 }
validates :reward, numericality: { only_integer: true, in: 0..100_000 }
# Other visitors. The stream replaces the line inside the row, not the
# row: a visitor who has the row open has no such line on the page, so
# their form is left alone. Replacing the frame would take the form away
# from under them.
after_update_commit do
broadcast_replace_to "quests", target: dom_id(self, :line),
partial: "quests/line", locals: { quest: self }
end
end
<%= turbo_stream_from "quests" %>
<ul>
<%= render partial: "quests/quest", collection: @quests %>
</ul>
<%# A closed row: a frame, and in it a link to the form. The frame's id is
what `edit` and `update` answer with. %>
<li>
<%= turbo_frame_tag quest do %>
<%= link_to edit_quest_path(quest) do %>
<%= render "quests/line", quest: %>
<% end %>
<% end %>
</li>
<%# What the broadcast replaces for other visitors. %>
<span id="<%= dom_id(quest, :line) %>">
<%= quest.name %>
<b><%= number_with_delimiter(quest.reward) %> gp</b>
</span>
<%# The same frame id as the closed row, so Turbo swaps one for the other.
A save that is turned down renders this again with a 422. %>
<%= turbo_frame_tag @quest do %>
<%= form_with model: @quest,
data: { controller: "inline-edit", action: "keydown.esc->inline-edit#cancel" } do |form| %>
<%= form.text_field :name, autofocus: true, data: { inline_edit_target: "field" } %>
<%= form.number_field :reward, min: 0 %>
<%= form.submit "Save" %>
<%= link_to "Cancel", quest_path(@quest), data: { inline_edit_target: "cancel" } %>
<% @quest.errors.each do |error| %>
<p><%= error.full_message %></p>
<% end %>
<% end %>
<% end %>
import { Controller } from "@hotwired/stimulus"
// `autofocus` puts the cursor in the field. Selecting the text and hearing
// Escape are still JavaScript.
export default class extends Controller {
static targets = ["field", "cancel"]
connect() {
this.fieldTarget.select()
}
cancel() {
this.cancelTarget.click()
}
}
resources :quests, only: %i[index show edit update]
Turbo Frames do this well, and the stock version is not long. What it has is more places. Which row is open is whatever each frame last fetched, so every way of changing it is a route, an action and a template that agree on the frame's id, and the broadcast has to aim at an element only a closed row has, or it takes the form away from a visitor typing in it. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| Which row is open | Whatever each row's frame last fetched | One state holding an id |
| Opening a row | A route, an edit action and a template wrapped in the row's frame |
One action that writes the state |
| Cancel | A link to show, which renders the closed row |
One action that writes nil |
| A save that is turned down | Render edit again with a 422 |
Nothing; the effect follows the form's errors |
| The row after a save | update renders the closed row's partial |
Nothing; the effect follows the rows |
| Other visitors see the save | A stream on the page, a callback on the model and a partial for it to render | A ping from the model and a number the channel bumps |
| Someone saves while your row is open | Aim the broadcast at an element only a closed row has, or it replaces your form | Nothing; each tab draws the log with its own open row |
| Only one row open at a time | Not without more JavaScript; each frame opens by itself | Nothing; the state holds one id |
| The cursor in the field | The autofocus attribute, and a Stimulus controller to select the text |
focus(select: true) on the field |
| Escape | A Stimulus controller that clicks Cancel | event: "keydown.esc" on the form's wrapper |
| The cursor after the row closes | Lost, or more of the Stimulus controller | Nothing; the client returns it to the row's button |
| JavaScript you write | One Stimulus controller | None |
| What a click costs | One request for the form | One round trip over the open socket |
| What a keystroke costs | Nothing | One message, after a short wait |
The two versions are close here, and the last row is where Hibiki pays more: the stock version sends nothing for a keystroke, and here each one is a message after a short wait, which is what brings your typing back when someone else saves. This recipe makes an assumption: when two visitors save the same quest, the last save wins. If you'd prefer to grant the user the privilege to solve the conflict, Presence and Conflicts is the recipe that does that.