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

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:

  1. The row's button calls edit with the quest's id. edit fills the form from the quest and writes the id to @editing_id.
  2. 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.
  3. 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

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