Photo by Mika Baumeister on Unsplash
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.
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