Inline Editing and Deleting with Hotwire - Part 2

Part 17 of building a Rails 7 application

This second part will cover how to edit records from the index page and also how to reflect those changes on any other page that may be viewing those records.

Here is what will be working by the end of this blog (spoiler alert!). Editing the text field and toggling the boolean are saved and reflected in another screen as well (the "Edit" page).

The approach I'll be taking for making inline editing working is by using a combination of Turbo Streams and Turbo Frames with a sprinkling of Stimulus controllers to trigger saving.

Firstly I want to convert the current method of rendering the content of the <td>'s in the table across to a ViewComponent, specifically for those fields that need to be editable inline.

app/components/editable_cell/component.rb

module EditableCell
  class Component < ViewComponent::Base
    attr_reader :url, :resource, :attribute, :formatter

    def initialize(url:, resource:, attribute:, formatter: :string)
      super
      @url = url
      @resource = resource
      @attribute = attribute
      @formatter = formatter
    end

    def display
      public_send("#{@formatter}_formatter")
    end

    def string_formatter
      resource.public_send(@attribute)
    end
  end
end

app/components/editable_cell/component.html.erb

<div id="<%= dom_id(resource, "#{attribute}_turbo_stream") %>">
  <turbo-frame id="<%= dom_id(resource, "#{attribute}_turbo_frame") %>" class="contents">
    <%= link_to url, class: "editable-element" do %>
      <%= display %>
    <% end %>
  </turbo-frame>
</div>

And here is one being rendered for the name attribute on ItemSellPack:

app/views/item_sell_packs/_row.html.erb

  <td id="<%= dom_id(resource, :name_cell) %>" class="whitespace-nowrap border-b border-gray-200 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 lg:pl-8">
    <%= render EditableCell::Component.new(url: polymorphic_path([:edit, resource]), resource: resource, attribute: :name) %>
  </td>

So what is this doing then? There is an a tag being produced (using link_to) with a content being produced by the display method. Currently it handles being formatted as a string but in the future there will be other types such as timestamps and so on.

The link is refering to the "Edit" page of the resource. Because this link is wrapped in a <turbo-frame> tag and the corresponding "Edit" page has a <turbo-frame> tag with the same name Turbo will simply take that fragment and replace the a with an editable field using all the same markup as the "Edit" page.

Recall the TextComponent had this:

    <turbo-frame id="<%= dom_id(resource, "#{attribute}_turbo_frame") %>" class="contents">
      <input
        class="block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md"
        autofocus="autofocus"
        type="text"
        value="<%= resource.public_send(attribute) %>"
        name="<%= field_name %>"
        id="<%= field_id %>"
        data-action="<%= data_actions.join(' ') %>"
        data-editor-url-param="<%= polymorphic_path(resource) %>"
        data-editor-field-id-param="<%= field_id %>"
        data-editor-attribute-param="<%= attribute %>"
      >
    </turbo-frame>

The Turbo Frames are the same:

<turbo-frame id="<%= dom_id(resource, "#{attribute}_turbo_frame") %>"

Clicking the link will now show an editable field.

image.png

I'll need some Stimulus to control what happens next:

app/javascript/controllers/editor_controller.js

import {patch} from "@rails/request.js";
import {Controller} from "@hotwired/stimulus"
import cash from "cash-dom"

export default class extends Controller {
  editNextAttribute(id) {
    const currentRow = cash(`#${id}`).closest('.collection-rows__row')
    const nextRow = currentRow.next('.collection-rows__row')
    nextRow.find('.editable-element').trigger('click')
  }

  async save(event) {
    if (event.key === 'Enter' || event.key === 'Tab') {
      event.preventDefault();
      const resourceUrl = event.params.url
      const fieldId = event.params.fieldId
      const attribute = event.params.attribute
      const body = {}

      body[attribute] = event.currentTarget.value
      const response = await patch(resourceUrl, {body: body, responseKind: 'turbo-stream'})
      if (response.ok) {
        this.editNextAttribute(fieldId)
      } 
    }
  }

  cancel(event) {
    if (event.key === 'Escape' || event.key === 'Esc') {
      event.preventDefault();
      const resourceUrl = event.params.url
      const attribute = event.params.attribute
      const resourceName = event.params.resourceName

      get(`${resourceUrl}?${resourceName}[${attribute}]=`, {responseKind: 'turbo-stream'})
    }
  }
}

Starting with the cancel method this will allow the user to go from the editable state back to the display state. By passing in the attribute to the show controller the server knows which EditableCell to re-render and how.

The save method is not too different, it does a patch rather than a get in order to actually save the results. If that is successful it is currently moving the editable cell to the next row, sort of how it would work in Microsoft Excel.

Speaking of the controller, here is what it is doing to make this work. It examines the parameters being passed in and pulls the first one out to be used to render the EditableCell.

app/controllers/item_sell_packs_controller.rb

      format.turbo_stream do
        render(
          partial: 'editable_cell',
          locals: { attribute: editable_cell_attribute, formatter: editable_cell_formatter },
          status: :ok
        )
      end

  def editable_cell_attribute
    resource_params.keys.first
  end

  def editable_cell_formatter
    resource_class.attribute_types[editable_cell_attribute].type
  end

The editable_cell turbo stream partial is where the HTML replacement occurs:

app/views/application/_editable_cell.turbo_stream.erb

<turbo-stream action="replace" target="<%= dom_id(@resource, "#{attribute}_turbo_stream") %>">
  <template>
    <%= render EditableCell::Component.new(
        url: polymorphic_path([:edit, @resource]),
        resource: @resource,
        attribute: attribute,
        formatter: formatter
    ) %>
  </template>
</turbo-stream>

Notice how the target for the Turbo Stream is the same as the EditableCell::Component listed above. This is how Turbo knows which fragment to replace after the user clicks the link or saves/cancels an edit.

<div id="<%= dom_id(resource, "#{attribute}_turbo_stream") %>">

There is one more thing to get working on the index page that doesn't quite fit into the EditableCell concept and that is the toggle for Canonical. The user would be expecting that clicking the toggle switch will have an immediate effect, and thus loading up the "Edit" page version of the toggle switch doesn't make much sense.

Here is what this toggle element looks like on the index page:

app/views/item_sell_packs/_row.html.erb

<tr class="collection-rows__row" id="<%= dom_id(resource, "turbo_stream") %>" data-controller="resource-form--component editor">
  <td id="<%= dom_id(resource, :canonical_cell) %>" class="whitespace-nowrap border-b border-gray-200 px-3 py-4 text-sm text-gray-500">
    <input
      type="hidden"
      value="<%= resource.canonical %>"
      name="<%= "#{resource.resource_name}[canonical]" %>"
      id="<%= dom_id(resource, :canonical) %>"
    >
    <!-- Enabled: "bg-indigo-600", Not Enabled: "bg-gray-200" -->
    <button
      type="button"
      class="<%= resource.canonical? ? 'bg-indigo-600' : 'bg-gray-200' %> relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      role="switch"
      aria-checked="false"
      data-action="click->resource-form--component#toggle click->editor#toggle"
      data-editor-url-param="<%= polymorphic_path(resource) %>"
      data-editor-attribute-param="canonical"
      data-editor-field-id-param="<%= dom_id(resource, :canonical) %>"
      data-resource-form--component-toggle-id-param="<%= dom_id(resource, :canonical_toggle) %>"
      data-resource-form--component-field-id-param="<%= dom_id(resource, :canonical) %>"
    >
      <span class="sr-only">Canonical</span>
      <!-- Enabled: "translate-x-5", Not Enabled: "translate-x-0" -->
      <span id="<%= dom_id(resource, :canonical_toggle) %>" aria-hidden="true" class="<%= resource.canonical? ? 'translate-x-5' : 'translate-x-0' %> pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
    </button>
  </td>
</tr>

This page has been connected to two Stimulus controllers. One has already been presented in previous blogs (click->resource-form--component#toggle). This method changes the appearance of the toggle and sets the correct value of the hidden field. The other event is new - click->editor#toggle. This means adding a new method to the editor controller:

`app/javascript/controllers/editor_controller.js

  toggle(event) {
    event.preventDefault();
    const resourceUrl = event.params.url
    const attribute = event.params.attribute
    const input = cash(`#${event.params.fieldId}`)
    const body = {}

    body[attribute] = input.val()
    patch(resourceUrl, {body: body, responseKind: 'json'})
  }

It is not too dissimilar to the save, but it does not advance the editable field and it patches via json rather than turbo_stream. I do not want anything to be replaced here. The toggle can stay as it is in its new state ready to be toggled again.

This is all the inline editing working, there are some tests for this as well as follows:

test/system/item_sell_packs_test.rb

  test 'inline editing and cancelling' do
    visit item_sell_packs_url

    # Click to edit
    click_on 'carton'
    assert_selector("input#item_sell_pack_#{item_sell_packs(:carton).id}_name")

    # Escape to cancel
    find("input#item_sell_pack_#{item_sell_packs(:carton).id}_name").send_keys(:escape)
    assert_selector("a[href=\"#{polymorphic_path([:edit, item_sell_packs(:carton)])}\"]", text: 'carton')

    # Enter to save
    click_on 'carton'
    find("input#item_sell_pack_#{item_sell_packs(:carton).id}_name").send_keys('pack', :enter)
    assert_selector("a[href=\"#{polymorphic_path([:edit, item_sell_packs(:carton)])}\"]", text: 'pack')
  end

However there is now a problem with the existing "New" and "Edit" pages. As soon as a format.turbo_stream response is added Rails will use that as the default for incoming requests which means that when redirecting after the record is saved or updated it attempts to do that via a Turbo Stream. For "New" and "Edit" pages this is not how I want that to work (at least for now). To fix it is simple, I can force the redirection to be HTML like so:

redirect_to(
  resource_url(@resource, { format: :html }),
  notice: "#{resource_human_name} was successfully created."
)

The last part for this section is broadcasting any changes that one user makes to other users (or even the same user across different pages). These need to be added as callbacks to the models. To facilitate that I have extracted a concern:

app/models/concerns/broadcast.rb

module Broadcast
  extend ActiveSupport::Concern

  included do
    after_update_commit lambda {
      broadcast_replace_later_to(
        resource_name_plural,
        partial: "#{resource_name_plural}/row",
        locals: { resource: self },
        target: "turbo_stream_#{resource_name}_#{id}"
      )
    }
    after_update_commit lambda {
      broadcast_replace_later_to(
        resource_name,
        partial: "#{resource_name_plural}/resource",
        locals: { action: :show, resource: self, readonly: true, token: form_authenticity_token },
        target: "turbo_stream_show_#{resource_name}_#{id}"
      )
    }
    after_update_commit lambda {
      broadcast_replace_later_to(
        resource_name,
        partial: "#{resource_name_plural}/resource",
        locals: { action: :edit, resource: self, readonly: false, token: form_authenticity_token },
        target: "turbo_stream_edit_#{resource_name}_#{id}"
      )
    }
  end

broadcast_replace_later_to is part of the Turbo Broadcastable concern. It says it will broadcast the specified partial to the resource_name channel such that it will replace the given target. Because it is "later" it will be asynchronous and therefore won't hold up the current thread.

The first callback is for the index page, if it detects a change in a record it will find any subscribers to the stream and replace the content of that record's row with the updated row partial. To subscribe to these events the page needs to include a tag:

app/views/item_sell_packs/index.html.erb

<%= turbo_stream_from "item_sell_packs" %>

I can test that this works properly in Capybara by simulating a second window with the same index page on it:

test/system/item_sell_packs_test.rb

  test 'multiple tabs' do
    visit item_sell_packs_url
    new_window = open_new_window
    within_window new_window do
      visit item_sell_packs_url
    end

    click_on 'carton'
    assert_selector("input#item_sell_pack_#{item_sell_packs(:carton).id}_name")
    find("input#item_sell_pack_#{item_sell_packs(:carton).id}_name").send_keys('pack', :enter)
    assert_selector("a[href=\"#{polymorphic_path([:edit, item_sell_packs(:carton)])}\"]", text: 'pack')

    within_window new_window do
      assert_selector("a[href=\"#{polymorphic_path([:edit, item_sell_packs(:carton)])}\"]", text: 'pack')
    end
  end

rdoc.info/gems/turbo-rails/0.5.8/Turbo/Broa..