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.
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 setting
this.focusedValueto
true` 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.
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.
In the next post, we'll take a look at how to sort this list of ContentElements
and descendants.