Working with CableReady
CableReady is a simple library with a lot of power.
You can figure out the basics in a few moments, but there is a wealth of optional features and enough syntactic sugar to give a large ant colony insulin shock, too.
Passing extra options to operations
In addition to the standard, documented options for each operation, you can pass additional application-specific data to the client. These JSON-compatible options will be ignored by CableReady but available via life-cycle events in the detail
object.
You can use these ad hoc options to send extra information such as UUIDs and even rendered bits of HTML to the client.
cable_ready["user"].set_cookie(
cookie: "favorite_food=pasta",
foo: "bar"
).broadcast
Selectors
The selector
option provided to DOM-mutating operations expects a CSS selector that resolves to one single DOM element. The default element for all operations is document
unless it is changed.
If multiple elements are returned, only the first one is used - unless the select_all
option is used.
selector
as optional first argument
Since most CableReady operations require a selector
, we made it the optional default first parameter to an operation - saving you some precious keystrokes. Just remember: it has to be first:
cable_ready
.inner_html("#red", html: "<b>Red wins!</b>")
Note that if you try to specify selector
both ways in one operation, the one in the Hash will take priority:
cable_ready
.inner_html("#red", selector: "#green", html: "<b>Green wins!</b>")
selector
will accept AR models and relations
You can pass selector (parameter and key/value, both) anything you can pass to dom_id
, including models (like User.first
, which beomes #user_1
) and relations (User.all
becomes #users
).
cable_ready
.inner_html(User.first, html: "<span>John Doe</span>")
selector
remembers the previous selector
You know what sucks? Repeating yourself.
Each CableReady channel remembers the selector
from the previous operation, if any. This means that you can specify a selector at the beginning of a chain, and it will automatically be picked up by succeeding operations until a new selector is used, at which point that selector becomes the default selector for all following operations.
If a new selector is used, all previously used selectors are unmodified.
cable_ready
.set_focus("#users")
.inner_html(html: "<span>All Users</span>")
.set_style(name: "color", value: "red")
.text_content(selector: User.all, text: "Bloom")
Operating on multiple elements
Many DOM Mutation and Element Property Mutation operations support a select_all
option which instructs CableReady to operate on one or many DOM elements returned by the selector
query.
This technique is quite powerful because it can scoop up elements from multiple locations in the DOM based on their element type, id property, CSS class list or attributes. For example, you could grab every element with an instance of a Stimulus controller called sushi
:
cable_ready
.text_content(select_all: true, selector: "[data-controller='sushi']")
WARNING
Each element will emit their own "before" and "after" events as part of the same operation.
Any information you change in these events could modify the behavior of the operation for the other elements that were selected. 🐉🐉🐉
xPath
CableReady also supports the use of XPath query expressions to locate elements based on their location relative to the document root in the DOM hierarchy. 🗺️
To interpret a selector as an XPath query, you need to do two things:
- Pass
xpath: true
as a parameter argument when enqueueing the operation. - Provide an XPath query that matches the desired element, and then ensure its validity until the operation has completed.
Here's a function that can produce an XPath expression for any DOM element:
const elementToXPath = element => {
if (element.id !== '') return "//*[@id='" + element.id + "']"
if (element === document.body) return '/html/body'
let ix = 0
const siblings = element.parentNode.childNodes
for (var i = 0; i < siblings.length; i++) {
const sibling = siblings[i]
if (sibling === element) {
const computedPath = elementToXPath(element.parentNode)
const tagName = element.tagName.toLowerCase()
const ixInc = ix + 1
return `${computedPath}/${tagName}[${ixInc}]`
}
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) ix++
}
}
An XPath-powered operation might look like:
text_content(
selector: "/html/body/div[3]/div[1]/article[1]/section[5]/ul[1]/li[10]/div[1]/div[2]",
xpath: true,
text: "XPath is under-utilized, but beware of side-effects changing your DOM."
)
It's very likely that you'll never need to use XPath in your applications and possibly even in your career. However, it's one of those things that when you need to build a list of children of the siblings of the current element's parent, you'll be really thankful that we included it.
TIP
You can grab the XPath selector for any element using your browser's Element Inspector. Activate the desired element, right-click and select "Copy", then "Copy full XPath".
Helpful references for working with XPath include the only time W3Schools will be linked to from this site, the XPath cheatsheet, this ultimate cheatsheet, and finally, an exhaustive cheatsheet.
Takeaway: XPath is extremely popular with cheaters.
WARNING
XPath selectors cannot be used with the select_all
option, although if this is important to your application, let us know and we'll consider a more flexible implementation.
Operation Execution Order
CableReady executes operations in the order that they are created:
cable_ready.console_log(message: "3").console_log(message: "1")
cable_ready.console_log(message: "2")
You will see the following in your Console Inspector:
3
1
2
Operation Batches
CableReady operations can be grouped together using the batch
option, which accepts either true
or a string. The goal of a batch is to know when all operations in a batch have been executed, even if other operations in a broadcast are still being executed.
When all operations in a batch have been executed, CableReady will emit a cable-ready:batch-complete
DOM event on the document
. The detail
object of the event will contain a key called batch
which contains the name of the batch that was completed.
Each operation can belong to a maximum of one batch. Batches do not impact the order in which operations are executed.
Batches are reset after each broadcast
- there is no such thing as a multi-broadcast batch. One broadcast can include operations that are batched alongside un-batched operations.
Life-cycle events
All CableReady operations emit a DOM CustomEvent immediately before an operation is executed, and another immediately after.
Events are emitted from the target selector
if present; otherwise, they will default to document
. Consult the documentation for each operation to confirm which object to listen to.
The event names follow a predictable pattern, as seen with cable-ready:before-inner-html
and cable-ready:after-morph
.
These events bubble and can be cancelled.
If jQuery is detected on the current page, jQuery events will be triggered immediately after the DOM events. These jQuery events have the same name and detail
accessors as their DOM event siblings.
A small number of operations, such as dispatch_event
and console_log
, do not emit events. It's up to you to make sure that any custom operations you create raise events, if desired.
INFO
Don't confuse Life-cycle events emitted by operations with the dynamically defined events you can send using dispatch_event
operations. The ways you can capture them are the same, but Life-cycle events are part of the CableReady library behaviour whereas dispatched events are ad hoc and not constrained to CableReady operations.
Listening for events
You can create a callback function to handle the life-cycle events CableReady emits, and then register that callback as an event listener without any special tools.
Create named functions (and avoid anonymous functions) for your callbacks because it's impossible to remove an event listener with an anonymous callback.
const afterMorphHandler = event => console.log(event.detail)
document.addEventListener('cable-ready:after-morph', afterMorphHandler)
Once you have captured an event, you can access all parameters provided to an operation via the detail
object. Remember, all snake_case keys will be automatically converted to camelCase:
const setCookieHandler = event => console.log(event.detail.cornPop)
document.addEventListener('cable-ready:after-set-cookie', setCookieHandler)
TIP
It's easier to track related concepts transactionally in one broadcast than it is to assemble data from multiple broadcasts back into a coherent state.
Staggering operations
Sometimes, it can be hard to get the timing of things *just right*. CableReady is here to help, in the form of the delay
option that is available for every operation.
By default, CableReady runs operations in the order that they are received. However, if an integer delay
is provided, the execution of that operation will be delayed by n
milliseconds. It is as if that particular operation is wrapped in a setTimeout
, which is exactly correct.
cable_ready
.console_log(message: "3")
.console_log(message: "2", delay: 1000)
.console_log(message: "1", delay: 2000)
.console_log(message: "Blast off?")
Remember: the individual operations are not aware of each other, so the delay is not cumulative. Plus, if you put a non-delayed operations after a delayed operation, the non-delayed operation will still fire immediately. The results of the example above will be:
3
Blast off?
2
1
You'll see the 3
and Blast off?
immediately, followed by the 2
after a second, and 1
after another second.
Modifying operations before they run
Almost all operations emit cable-ready:before-{operation}
and cable-ready:after-{operation}
events. If you create an event handler to listen for "before" events, you can access and modify most of the parameters passed when queueing the operation on the server.
Continuing the set_cookie
example, the event handler is able to intercept the operation mid-flight and change the parameters.
setCookieHandler = event => {
event.detail.cookie = 'favorite_food=yams'
}
WARNING
If your operation is processing multiple elements, each element will emit its own "before" and "after" events. If you change any values in the event.detail
object, this new value will be picked up by the other elements associated with the current operation that have not been processed, yet.
You could conceivable change a value during the "before" callback, then change it back to the original value during the "after" callback. This is almost certainly something you don't want to need to be able to do.
Cancelling operations
Almost all operations accept a cancel
parameter that is designed to be interacted with in a "before" event handler on the client. cancel
must be false
or undefined
when the event handler returns for the operation to run.
setCookieHandler = event => {
event.detail.cancel = true
}
Cancelled operations still emit an "after" event, but their primary functionality will not occur.
For example, if you have a long-running Reflex operation, the user might click a cancel button and proceed with their business. When the Reflex finally completes, your event handler can cancel the operation and prevent whatever would have happened.
WARNING
The server will have no idea that the operation was cancelled on the client. If this would create an inconsistency, you should send a cancel notification to the server, perhaps with a Nothing Reflex.
While most developers will never think about or interact with the cancel
parameter, it's an important tool to have available when building sophisticated client user interfaces.
DANGER
As with modifying detail
data, if your operation is processing multiple elements, each element will emit its own "before" and "after" events. You could cancel an operation for a given element and then un-cancel it for later elements.
You could jump out of an airlock into space, too. Don't say we didn't warn you! 👨🚀
Single Source of Truth
CableReady was created with a deep and informed belief that webapplications that maintain state on the server are fundamentally easier to design, build and maintain.
However, one of the stranger edge-cases that must be handled in a websockets-enabled world is the potential for a server update to overwrite the value of a text input while the user is typing into it. It's a jarring example because it's an end-result that was almost completely impossible to achieve in the Ajax era. Despite our wildest brainstorms, we have yet to identify even a single scenario where a user would consider having their effort undone to be positive.
As a result, CableReady's popular morph operation comes pre-installed with a shouldMorph callback called verifyNotMutable
that actively prevents the server from overwriting input, textarea and select elements while they are active (have focus).
Since forms are rarely designed to be edited by multiple concurrent users 😱 it's unlikely that you'll have to spend time thinking about this issue. If you're one of the lucky ones, you can use CableReady and StimulusReflex to establish a field-level locking system, or at the very least, update CSS or nearby indicators to show that a particular input is locked, contested or potentially out of date.
Focus assignment
The DOM Mutation operations accept an optional focusSelector
parameter that allows you to specify a CSS selector to which element should be active (receive focus) after the operation completes.
If focusSelector
is not specified, the focus will go to the element that was active immediately before the operation was executed.
It is possible to perform an operation that removes the previously active element, leaving the focus in an ambiguous state. It's also possible to use the set_focus
operation to manually set the focus at any time.
Channel generator
CableReady provides a Rails generator that you can use to create ActionCable Channel
classes and the client-side code required to subscribe to it.
Just provide it with the name of the channel class that you want to create, or pass --help
to see all options:
rails generate cable_ready:channel Sailor
The generator is interactive and will take you on a Choose Your Own Adventure through the decision tree of possible outcomes.
The first consideration is whether you want your Channel
to stream to a resource using broadcast_to
or will you broadcast
from a string identifier? The details of these concepts are explored fully in Stream Identifiers and Broadcasting to Resources. You can provide one of either --stream-from
or --stream-for
with a value, or it will prompt you if you don't specify.
Broadcasting to a resource
If you answer yes to the broadcast_to
question, it will then ask you for the class name of the resource you want to stream, just in case it's different from the class name of the Channel
that you're creating. Assuming that you went with the default "Sailor", you'll now have a Sailor Channel
:
class SailorChannel < ApplicationCable::Channel
def subscribed
stream_for Sailor.find(params[:id])
end
end
The generator will then ask if you're going to use Stimulus to subscribe to the Channel
. Even though CableReady does not require that you use Stimulus, we definitely recommend it as the blessed path. In this case, if you answer no, the generator is finished and you're on your own when it comes to subscribing. You'll have a
If you answer yes, it will create a Stimulus controller that will subscribe to your Channel. The idea is that in your app/javascript/controllers/index.js
, you will import the ActionCable consumer.js
and attach it to your Stimulus application. This makes the connection available to all Stimulus controllers while ensuring that all subscriptions share the same ActionCable Connection
.
All you need to do is create an instance of the Stimulus controller on the markup (using a partial or ViewComponent) that sets the data-{controller}-id-value
attribute:
<div data-controller="sailor" data-sailor-id-value="<%= sailor.id %>"></div>
Now, whenever that Sailor partial is in the DOM, it will automatically subscribe itself to updates for the resource behind it. On the Ruby side, you can now do this:
cable_ready[SailorChannel]
.inner_html(html: "Howdy!")
.broadcast_to(Sailor.first)
Broadcasting to a string identifier
If you answer no to the broadcast_to
question, it will proceed to ask you for the stream identifier string that you'll be streaming from. Assuming that you accept the default "sailor", you'll now have a Sailor Channel
:
class SailorChannel < ApplicationCable::Channel
def subscribed
stream_from "sailor"
end
end
You are free to customize the string as required by your application. On the client, Channel
subscription classes load when your app loads and will stay connected, waiting for updates. It's up to you to decide whether this is appropriate for your application, and is out of scope for this section. Again, you'll find all of the details in the Stream Identifiers chapter.
Without any further modification, all users will receive all broadcasts sent to this Channel
on every page:
cable_ready["sailor"].inner_html(html: "Howdy!").broadcast
Integrating with StimulusReflex
StimulusReflex is the sister library to CableReady. It's... really great, actually.
Library | Responsibility |
---|---|
StimulusReflex | Translates user actions into server-side events that change your data, then regenerating your page based on this new data into an HTML string. |
CableReady | Takes the HTML string from StimulusReflex and sends it to the browser before using morphdom to update only the parts of your DOM that changed. |
⬆️ StimulusReflex is for sending commands to the server. 📡
⬇️ CableReady is for sending commands to the browser. 👽
INFO
A Reflex action is a reaction to a user action that changes server-side state and re-renders the current page (or a subset of the current page) for that particular user in the background, provided that they are still on the same page.
A CableReady operation is a reaction to some server-side code (which must be imperatively called) that makes some change for some set of users in the background.
If you would like to read more about using StimulusReflex with CableReady, please consult "Using CableReady inside a Reflex action".