Rendering A Local TimeStamp With Stimulus Using Hotwire And Lucee CFML
So far, in my exploration of Hotwire, I've looked at several features of Turbo Drive including partial rendering with Turbo Frames and dynamically updating the page with Turbo Streams. According to David Heinemeier Hansson (DHH), the Turbo family of features should get you 90% of the way through your application development. But, that last 10% of features needs to be implemented with custom JavaScript. And for this, Hotwire provides Stimulus controllers; or, what the Rails community refers to as "JavaScript sprinkles". To start looking at Stimulus, I wanted to create a demo that takes a ColdFusion provided UTC millisecond value and renders it in the user's local timezone.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
In my Angular-fronted ColdFusion applications, I usually return timestamps as UTC milliseconds in my API responses. Then, on the client-side, I can take that millisecond value, new
up a Date
object, and render a user-specific date/time string. However, since Hotwire applications aren't backed by an API (in the traditional sense), this UTC millisecond timestamp needs to be provided as a data
attribute on the DOM (Document Object Model).
Unlike an Angular application, where the JavaScript / TypeScript classes act as the "source of truth" and the DOM is reconciled to reflect the JavaScript values, the Hotwire philosophy seems to treat the DOM as the "source of truth"; and, through the use of the MutationObserver
API, will reconcile the JavaScript class properties to reflect the state of the DOM.
TWO-WAY RECONSILIATION: When you update properties in your Controller classes, Stimulus will often update the DOM to reflect those changes. In that sense, state reconciliation does flow in both directions.
This approach drives a lot of the JavaScript configuration into various DOM attributes. We see this in Turbo with attributes like data-turbo-action
and data-turbo-preload
; and, we see this in Stimulus with attributes like data-controller
. In fact, Stimulus uses data
attributes to manage Actions, Values, Targets, and Classes.
ASIDE: This emphasis on the "DOM as truth" plays very nicely with the fact that Hotwire is updating the rendering of the page using HTML over the wire. By driving state into the DOM, swapping out portions of the page naturally swaps out portion of the state.
For this local time demo, I'm going to be providing the UTC millisecond time as a "value" attribute. And, I'm going to be providing "daytime" and "nighttime" classes as "class" attributes that will be dynamically applied to the DOM based on the time-of-day.
Here is a truncated snippet of my demo:
<div
data-controller="local-time"
data-local-time-tick-count-value="#getTickCount()#"
data-local-time-day-time-class="local-time--day"
data-local-time-night-time-class="local-time--night"
class="local-time">
Your local time:
<span data-local-time-target="label"></span>
</div>
The data
attributes in this Stimulus controller view use the following conventions:
data-controller="{ identifier }"
data-{ identifier }-{ value }-value="..."
data-{ identifier }-{ class }-class="..."
data-{ identifier }-target="..."
Stimulus takes these data
attributes and maps them onto a whole host of properties, getters, setters, and callbacks on our controller classes. For example, the following value attribute:
data-local-time-tick-count-value
... results in the following Controller properties and callbacks:
- Getter:
this.tickCountValue
- Setter:
this.tickCountValue
- Existence:
this.hasTickCountValue
- Change Detection:
this.tickCountValueChanged( newValue, oldValue )
My Stimulus controller will then take this value, new
up a Date
, and render it in the user's local time via the target <span>
. Since I am not using the Rails asset pipeline, you'll see at the bottom that I am manually registering my JavaScript class with the local-time
identifier:
// Import core modules.
import { Application } from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
class LocalTimeController extends Controller {
// These static values tell Stimulus which DOM attribute to map to which JavaScript
// class properties. When the Controller is instantiated, a good number of getters
// and setters are automatically generated based on the following names.
static classes = [
"dayTime",
"nightTime"
];
static targets = [
"label"
];
static values = {
tickCount: Number
};
// ---
// LIFE-CYCLE METHODS.
// ---
/**
* I run once after the component instance has been bound to the host DOM element. At
* this point, all of the classes, targets, and values have already been bound.
*/
connect() {
console.info( "Controller connected." );
this.renderTime();
}
/**
* I get called once after the component instance has been unbound from the host DOM
* element. At this point, all of the targets have already been disconnected as well.
*/
disconnect() {
console.info( "Controller disconnected." );
}
/**
* I get called once after the "label" target is connected to the controller instance.
* This gets called BEFORE the main connect() call-back.
*/
labelTargetConnected( node ) {
console.log( "Label target connected." );
}
/**
* I get called anytime the "tick-count" value is changed (including the value
* initialization). This will get called once BEFORE the main connect() call-back.
*/
tickCountValueChanged( newValue, oldValue ) {
console.group( "Tick Count Changed" );
console.log( "New value:", newValue );
console.log( "Old value:", oldValue );
console.groupEnd();
if ( ! oldValue ) {
// When the tickCount value is first set (upon controller initialization), the
// previous value is "0" (the default). We know that the initial rendering of
// the local time will be handled by the connect callback; as such, we only
// want to respond when the value changes occur after the connect event.
return;
}
// Update the rendering to reflect the new time!
this.renderTime();
}
// ---
// PRIVATE METHODS.
// ---
/**
* I update the rendering of the local-time label based on the bound tick-count.
*/
renderTime() {
// Using the server-provided UTC milliseconds value, let's instantiate a Date
// object in the user's local timezone. This will give us access to locale-
// specific formatting.
var localTime = new Date( this.tickCountValue );
// To make the demo more interesting, I'm arbitrarily classifying some hours as
// "night" and other hours as "day" so that I can use some CSS class bindings.
var isNightTime = (
( localTime.getHours() < 7 ) ||
( localTime.getHours() > 19 )
);
this.labelTarget.textContent = localTime.toLocaleTimeString();
if ( isNightTime ) {
this.element.classList.remove( this.dayTimeClass );
this.element.classList.add( this.nightTimeClass );
} else {
this.element.classList.remove( this.nightTimeClass );
this.element.classList.add( this.dayTimeClass );
}
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
window.Stimulus = Application.start();
// When not using the Ruby On Rails asset pipeline / build system, Stimulus doesn't know
// how to map controller classes to data-controller attributes. As such, we have to
// explicitly register the Controllers on Stimulus startup.
Stimulus.register( "local-time", LocalTimeController );
As you can see, in my connect()
life-cycle method, I call renderTime()
. The renderTime()
method takes the UTC millisecond value, parses it into a date, and then sets the .textContent
of the target span to contain a localized time string. As such, when we run this ColdFusion demo, we get the following output:
As you can see, the ColdFusion server time is 12:14:44
; but, the UTC milliseconds have been rendered as 7:14:44
in the user's local time using our Stimulus controller.
Now, you might notice in that screenshot that there are links to manually set the time to either "Day" or "Night". I added these to see if dynamic changes to the DOM - which, remember, is used as the "source of truth" - will be reflected in the state of my controller class. Here's the full source of my index.cfm
page:
<cfmodule template="./tags/page.cfm">
<cfoutput>
<h1>
ColdFusion + Hotwire Local Time Controller Demo
</h1>
<div
id="demo"
data-controller="local-time"
data-local-time-tick-count-value="#getTickCount()#"
data-local-time-day-time-class="local-time--day"
data-local-time-night-time-class="local-time--night"
class="local-time">
Your local time:
<!---
Once the Stimulus controller is bound, the text inside this span will be
replaced with a formatted time string.
--->
<span data-local-time-target="label">
Unknown
</span>
</div>
<p>
<a href="logout.htm">Logout</a>
</p>
<!---
When Stimulus binds Controllers to the DOM (Document Object Model) tree, it
also starts to track changes to the DOM using the MutationObserver API. This
means we can make runtime changes to the DOM from outside the Controller and
Stimulus will turn around and trigger the Controller's life-cycle hooks. In
this case, we'll change the "tick-count" value attribute, which will, in turn,
trigger a re-render of the time.
--
NOTE: This is done, in part, because Stimulus "best practices" seem to try and
treat the DOM as the "source of truth" for the state of the components. This
approach allows HTML to be dynamically re-rendered with much less friction.
--->
<p>
Dynamically set attribute:
<a href="javascript:( window.demo.setAttribute( 'data-local-time-tick-count-value', new Date( '2023-02-06T11:30:00-05:00' ).getTime() ) ); void( 0 );">Set to Day</a> ,
<a href="javascript:( window.demo.setAttribute( 'data-local-time-tick-count-value', new Date( '2023-02-06T03:15:00-05:00' ).getTime() ) ); void( 0 );">Set to Night</a>
</p>
</cfoutput>
</cfmodule>
As you can see, those two links at the bottom are using a javascript:
protocol to reach into the DOM tree and call setAttribute()
on our Stimulus controller view. And, in doing so, we get the following output:
As you can see, when we manually update the DOM attribute, Stimulus notices that change (via the MutationObserver
API); and, updates the Controller class to reflect the new state of the DOM. Stimulus also invokes our change-callback for the tickCount
value, which we then use to update the CSS classList
being applied to the host element.
There's a lot more that can go into a Stimulus controllers - I'm just starting to scratch the surface. But, I can see how Stimulus dovetails nicely with Turbo. In fact, Stimulus will seamlessly and automatically connect and disconnect controllers as the state of the DOM is dynamically altered via Turbo Drive, Turbo Frames, and Turbo Streams.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →