Inline Editing with Kredis and StimulusReflex
The report will feature an e-book like reading experience for the customer, with cross references to code and other artifacts. To allow for a convenient writing experience for the reviewer, we would like to allow "engaging" blocks with a click, and disengaging them with a click outside, while the permissions of a regular customer allow only reading them.
How do we cater for that? Well, the PreviewableMarkdownAreaComponent
already knows how to toggle between editing/non-editing states: by providing a form or not. So our job is to provide another flag to decide whether to pass a form to our component or not; we can do this with Kredis.
Use Kredis to Store UI State on the Server
We bundle add kredis
and run its install
task, which simply provides a default Redis configuration. Let's restart puma to reflect the changes.
In our ContentElement
model, we just add a kredis_flag
to determine whether this specific block is being edited or not - we will provide for a more concurrency-allowing way later.
# app/models/content_element.rb
class ContentElement < ApplicationRecord
# ...
kredis_flag :editing # <---
# ...
end
So in order to toggle our content block between editing and non-editing states, we need to connect our injecting the form into the component to this flag.
<!-- app/views/content_elements/_content_element.html.erb -->
<!-- other markup omitted -->
<%= render(PreviewableMarkdownAreaComponent.new(
object: content_element,
form: policy(content_element).edit? && content_element.editing? ? form : nil,
attribute: :body
)) %>
If we check back in our browser, the blocks are now not showing the textarea anymore, because the kredis_flag
for each of those is false by default.
Toggle Editing State with StimulusReflex
To achieve the toggling of the editing state, we will pull in StimulusReflex. Let's generate an InlineEditReflex
with an enable
and disable
action.
$ bundle add stimulus_reflex
$ bin/rails stimulus_reflex:install
$ bin/rails g stimulus_reflex InlineEdit enable disable
First, we'll write a helper method to access the block in question from a signed global id stored in a sgid
attribute on the element's dataset. The enable
and disable
methods just serve as proxies to toggle the kredis
_flag.
class InlineEditReflex < ApplicationReflex
def enable
resource.editing.mark
end
def disable
resource.editing.remove
end
def resource
element.signed[:sgid]
end
end
Now let's patch up our markup to make this work. We will provide a data-sgid
attribute on the turbo-frame
with the ContentElement
's or Comment
's signed
id.
<!-- app/views/content_elements/_content_element.html.erb
<%= turbo_frame_tag dom_id(content_element), class: "block",
data: {sgid: content_element.to_sgid.to_s} do %>
<%= form_with model: content_element, data: {controller: "form"} do |form| %>
<%= render(PreviewableMarkdownAreaComponent.new(object: content_element, form: policy(content_element).edit? && content_element.editing? ? form : nil, attribute: :body)) do |area| %>
<% end %>
<% end %>
<% end %>
On the preview panel, we put a data-reflex
attribute to invoke our enable action, and constrain this to the case when our ViewComponent is not already in editing state. By specifying the data-reflex-dataset
to include all ancestors, we make sure that our data-sgid
actually is included in the dataset.
<!-- app/components/previewable_markdown_component.html.erb -->
<!-- preview tab -->
<div class="... <%= "hidden" if form.respond_to?(:text_area) %>" role="tabpanel" tabindex="0" data-tabs-target="panel"
data-reflex="<%= "click->InlineEdit#enable" unless form.respond_to?(:text_area) %>"
data-reflex-dataset="ancestors">
<div class="...">
<div class="prose ...">
<%= Kramdown::Document.new(body, input: "GFM", syntax_highlighter: "rouge").to_html.html_safe %>
</div>
</div>
</div>
In the browser, clicking on a block, it switches to editing mode, hooray! The customer, though, still isn't allowed to edit. Perfect.
To disable editing, we will need to do a bit more work. First, we connect our enclosing turbo-frame
to the inline-edit
stimulus controller. On focusout
, we trigger the disable action.
FocusEvent
Deep Dive
To understand the mechanics of the focusout
event, we need to take a look at its relatedTarget
property. In the context of a FocusEvent
, it has different meanings: Either the element receiving or losing focus (refer to MDN for more details). In the case of focusout
, it's the target receiving focus. Why is that important? Because we only want to trigger the disable
action when we click outside the block.
To make sure that this is the case, inside the inline-edit
Stimulus controller, whenever we invoke the disable
action, we make sure there is a relatedTarget
, and the element the Stimulus controller is connected to does not contain it. Only then do we stimulate the reflex, hence this little detour into JavaScript land.
// app/javascript/controllers/inline_edit_controller.js
export default class extends ApplicationController {
// ... more methods and callbacks omitted
disable (event) {
if (event.relatedTarget && !this.element.contains(event.relatedTarget)) {
this.stimulate('InlineEdit#disable')
}
}
}
There's one final tweak we need to do here. To make the focusout
work, we need to make the relatedTarget
actually focusable. To achieve that, we just set tabindex = -1
on the enclosing container.
Cater for Multiple Users
Okay, so far so good. Remember that before I talked about making our kredis attribute more multi-user-resilient? That's why we will change it from a simple flag to a set containing users' Global IDs. Here's a helper method to check if a certain user is currently included in this set - in other words, she is currently editing the record.
# app/models/content_element.rb
class ContentElement < ApplicationRecord
# ...
kredis_set :editing_users # <---
# ...
def active_for?(user)
editing_users.include?(user.to_gid.to_s)
end
end
In the reflex, instead of toggling the flag, we either append the current user's Global ID to this set or remove it. And that's it, now an element can be toggled by multiple users individually.
class InlineEditReflex < ApplicationReflex
def enable
resource.editing_users << current_user.to_gid.to_s
end
def disable
resource.editing_users.remove current_user.to_gid.to_s
end
def resource
element.signed[:sgid]
end
end
Whether that's actually a good idea and we'd rather lock the record for editing is a topic for another post.