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. 1
    Sunmere Salt × 12 and Dried fish × 6
    2 days
  2. 2
    The Gnome Mine Pickaxes × 3
    4 days
  3. 3
    Castle Gate Iron bars × 20 and Salt × 4
    3 days

The Cabbage Run

7 days
  1. 1
    The Cabbage Patch Cabbages × 40
    1 day
  2. 2
    The Inn at the Ford Cabbages × 15 and Ale × 8
    2 days
  3. 3
    Sunmere no trade
    3 days
  4. 4
    The Smithy Coal × 10
    1 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

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:

  1. The button calls nested_add with dom, the open form, and path, the list in it: here stops.
  2. The action finds the list by the two, checks there is room (six stops at most) and writes the array again with a new StopForm at the end.
  3. 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

The table lists each behaviour of the demo above and where it comes from.

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