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.

Editing a ContentElement in the Browser

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.

Editing a ContentElement in the Browser

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.

Supercharge Your Rails App

Upgrade Your Tech Stack for a Smoother Dev Experience and Increased Profits.

Order a Review