Manipulating Blocks with CableReady::Updatable

In a review, a reviewer should of course be able to create, update, and delete content blocks. Right now, it's possible to view accepted code comments, but we would also like to be able to create free form text. The obvious way to achieve this would be to add little block dividers in between blocks that will display a toolbar on hover.

A Dropdown Showing Content Block Options

Let's start in the ContentElement partial. We will just write down a bit of placeholder code for the moment. We render a BlockDividerComponent that we have yet to write after each ContentElement. This component will boast a popover slot holding a few buttons in a toolbar. We go on to pass in some form params for the new ContentElement to be created, such as the position in the list, and the polymorphic parent.

  <!-- app/views/content_elements/_content_element.html.erb -->
  <% cache [content_element, policy(content_element).edit?] do %>
    <%= render(PreviewableMarkdownComponent.new(...)) %>
    
+   <%= render(BlockDividerComponent.new) do |divider| %>
+     <% divider.popover(params: {
+       content_element: {
+         position: content_element.position + 1,
+         parent: content_element.parent.to_sgid.to_s
+       }}) %>
+   <% end %>
  <% end %> 

We head over to the Comment partial, and copy in the same view code. Only this time, we need to swap the content_element local for comment.

  <!-- app/views/comments/_comment.html.erb -->
  <% if comment.included? %>
    <!-- markup omitted -->

+   <%= render(BlockDividerComponent.new) do |divider| %>
+     <% divider.popover(params: {
+       content_element: {
+         position: comment.position + 1,
+         parent: comment.parent.to_sgid.to_s
+       }}) %>
+   <% end %>
  <% end %>

Using a ViewComponent to Facilitate Creating New Blocks

Now, let's actually generate this BlockDividerComponent. In it, we define a popover slot and tell it to use a ToolbarComponent to render it - we'll get to that in a second.

# app/components/block_divider_component.rb
class BlockDividerComponent < ViewComponent::Base
  renders_one :popover
end

Furthermore, we use a standard Stimulus controller to handle the dropdown functionality, which you will see in a moment. It consists of a button as the dropdown#toggle trigger element, and a menu target that will get toggled visible and invisible.

<!-- app/components/block_divider_component.rb -->
<div class="..." data-controller="dropdown">
  <!-- ... -->
  <button class="..." data-action="click->dropdown#toggle click@window->dropdown#hide" data-dropdown-target="button">
    <!-- "plus icon" --> 
  </button>
  <div data-dropdown-target="menu" class="...">
    <%= popover %>
  </div>
</div>

Adding a Toolbar Component

Back to our ToolbarComponent; here is the Ruby class, the initializer allows it to take params:

# app/components/toolbar_component.rb
class ToolbarComponent < ViewComponent::Base
  def initialize(params:)
    @params = params
  end
  
  attr_reader :params
end

Next, let's modify the component's template. Adding a button_to for a new ContentElement, and passing the params that we defined in the ContentElement partial, is enough to test it out for the first time.

<!-- app/components/toolbar_component.html.erb -->
<div class="flex space-x-2">
  <%= button_to ContentElement.new, {params: params} do %>
    <!-- markdown icon -->
  <% end %>
</div>

Enable CableReady::Updatable For Automatic Reactivity

Inspecting our browser's network tab, we find a POST request to the ContentElementsController including all the specified params. This looks like a success! But why is our new ContentElement not showing up? Well, it's there all right, if we refresh the browser we can see it. We can also fully leverage the inline editing functionality from the last post.

But the ContentElementsController#create action responded with a 204 No Content status. Why would we want to do that?

The answer is, we can make use of CableReady::Updatable again, to provide streaming update for all watching users. For this, all we actually have to do is pass enable_cable_ready_updates: true to the content_elements association in the relevant parent models, Section and Chapter.

  # app/models/section.rb
  class Section < ApplicationRecord
    # ...

-   has_many :content_elements, -> { order(position: :asc) }, as: :parent
+   has_many :content_elements, -> { order(position: :asc) }, as: :parent, enable_cable_ready_updates: true

    # ...
  end
  
  # app/models/chapter.rb
  class Chapter < ApplicationRecord
    # ...

-   has_many :content_elements, -> { order(position: :asc) }, as: :parent
+   has_many :content_elements, -> { order(position: :asc) }, as: :parent, enable_cable_ready_updates: true

    # ...
  end

Now, in the corresponding edit views, we just have to wrap our list of elements in a cable_ready_updates_for tag.

  <!-- app/views/sections/edit.html.erb -->
  <!-- ... -->
  
- <%= render @section.content_elements %>
+ <%= cable_ready_updates_for @section, :content_elements do %>
+   <%= render @section.content_elements %>
+ <% end %>

  <!-- ... -->


  <!-- app/views/chapters/edit.html.erb -->
  <!-- ... -->

- <%= render @chapter.content_elements %>
+ <%= cable_ready_updates_for @chapter, :content_elements do %>
+   <%= render @chapter.content_elements %>
+ <% end %>

  <!-- ... -->

Handling Autofocus

Before we can give this a final spin, we have to take care of another concern: Handling focus. After creating a new ContentElement, we would like to be able to instantly type in it. To achieve this, we'll create a (self-destructing) Stimulus controller.

We'll define a focus target, and a focused value of type Boolean. Next, we implement a value changed listener for this value, performing focus() on the focusTarget whenever the value changes to true", else we call "blur. Most of the time I find it a good practice to handle state via the valuesinterface in Stimulus, because it gives you the chance to modify it either declaratively in the markup, or imperatively from code. So settingthis.focusedValuetotrue` will trigger the above callback, if it has changed.

// app/javascript/autofocus_controller.js
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  static targets = ['focus']
  static values = {
    focused: Boolean
  }

  focusValueChanged () {
    if (this.hasFocusTarget) {
      if (this.focusedValue) {
        this.focusTarget.focus()
      } else {
        this.focusTarget.blur()
      }
    }
  }
  
  focus(event) {
    event.preventDefault()
    
    this.focusValue = true
  }
}

To streamline matters a bit regarding CableReady::Updatable, let's create an Updatable controller concern. In it, we set an instance variable, @is_updatable_request, to true if the magic X-Cable-Ready: update header is present on the request. This will make it easier to distinguish between regular requests and such made by CableReady::Updatable in our controller and view code.

# app/controllers/concerns/updatable.rb

module Updatable
  included do
    before_action do
      @is_updatable_request = 
        request.headers["X-Cable-Ready"] == "update"
    end
  end
end

After including it in our ContentElementsController, we add a focused parameter to our PreviewableMarkdownAreaComponent.

  # app/controllers/content_elements_controller.rb
  class ContentElementsController < ApplicationController
+   include Updatable
    
    # ...
  end
  # app/components/previewable_markdown_component.rb
  class PreviewableMarkdownComponent < ViewComponent::Base
    # ...
    
-   def initialize(object:, form: nil, attribute: :body,
-     preview: false)
+   def initialize(object:, form: nil, attribute: :body,
+     preview: false, focused: false)
      @object = object
      @form = form
      @attribute = attribute
      @preview = preview
+     @focused = focused
    # ...
  end

In its view template, we add the autofocus controller, and set the focused value. We'll trigger the focus action manually when invoking the write tab, so the textarea will gain focus. For this, we have to make it the autofocus controller's focus target.

  <!-- app/components/previewable_markdown_component.html.erb -->
- <div data-controller="tabs" ...>
+ <div data-controller="tabs autofocus" ... data-autofocus-focused-value="<%= focused %>" >
  <% if form.respond_to?(:text_area) %>
    <div class="...">
      <div class="..." aria-orientation="horizontal" role="tablist">
-       <a href="#" class="..." data-tabs-target="tab" data-action="click->tabs#change">Write</a>
+       <a href="#" class="..." data-tabs-target="tab" data-action="click->tabs#change click->autofocus#focus">Write</a>
        <a href="#" class="..." data-tabs-target="tab" data-action="click->tabs#change">Preview</a>
      </div>
    </div>

    <!-- ... -->
-   <%= form.text_area attribute, class: "...", autofocus: true, data: {action: "debounced:input->form#requestPreview"} %>
+   <%= form.text_area attribute, class: "...", autofocus: true, data: {action: "debounced:input->form#requestPreview", autofocus_target: "focus"} %>
    <!-- ... -->
  </div>

Heading back to the ContentElement partial, we set the initial value of focused to whether or not we are dealing with an "Updatable" request. That way, after content is added, it will always gain focus.

  <!-- app/views/content_elements/_content_element.html.erb -->
- <%= render(PreviewableMarkdownComponent.new(...)) %>
+ <%= render(PreviewableMarkdownComponent.new(..., focused: @is_updatable_request)) %>
  
  <%= render(BlockDividerComponent.new) do |divider| %>
    <% divider.popover(params: {
      content_element: {
        position: content_element.position + 1,
        parent: content_element.parent.to_sgid.to_s
      }}) %>
  <% end %>

Demo Time

Now, finally, time for a demo. When I click "Add Markdown", a text area appears that instantly has focus. We can write and preview, as expected.

Adding a Markdown Block

Deleting Blocks Reactively

To demonstrate how CableReady::Updatable can take care of all CRUD actions, let's look at the destroy action. I'll just paste in some ERB into the ContentElement partial containing a button_to targeting the DELETE method.

  <!-- app/views/content_elements/_content_element.html.erb -->
  <!-- ... -->
+ <%= button_to content_element, method: :delete, class: "...", form: { data: { turbo_confirm: t("are_you_sure") } } do %>
+   <!-- trash icon -->
+ <% end %>
  <!-- ... -->
  
  <%= render(PreviewableMarkdownComponent.new(..., focused: @is_updatable_request)) %>
  
  <%= render(BlockDividerComponent.new) do |divider| %>
    <% divider.popover(params: {
      content_element: {
        position: content_element.position + 1,
        parent: content_element.parent.to_sgid.to_s
      }}) %>
  <% end %>

We now have a little trash button here that we can click on. After confirming, the ContentElement in question is deleted from the database, and the view is automatically updated.

Destroying a Block

In the next post, we'll take a look at how to sort this list of ContentElements and descendants.

Supercharge Your Rails App

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

Order a Review