Rendering A Persistent Dismissible Banner Using Hotwire And Lucee CFML
When operating a Single Page Application (SPA) in Angular, I will often need to render a persistent banner across the top of page, alerting the user to some sort of state change or a call-to-action (CTA). Now that I know that Hotwire can define persistent Turbo Frames; and, that we can use custom Turbo Stream actions to visit those Turbo Frames; I wanted to see if I could use a persistent frame to render a dismissible banner in a Hotwire-enhanced ColdFusion application.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
For the sake of simplicity, the state of our site-wide banner is going to be stored in an application
-scoped variable:
application.bannerText = "";
When this state variable is set, the banner will be rendered. And, when this state variable is cleared, the banner will be removed. And, since I want this banner to be persistent across the entire ColdFusion site, it needs to be rendered inside my main page layout. So, while Hotwire will help is persist across page navigations, we still need ColdFusion to handle the initial rendering.
Here is a truncated version of my main page layout. It is implemented as a ColdFusion custom tag, wherein thistag.generatedContent
is the individual request content being projected into the page layout:
<cfsavecontent variable="thistag.generatedContent">
<cfoutput>
<!doctype html>
<html lang="en">
<body>
<!---
NOTE: Our banner Turbo Frame is marked Permanent. This means that once
Turbo Drive takes over the application navigation, the SRC attribute will
only be evaluated once. Then, the permanent state will be re-rendered on
each subsequent page load.
--->
<turbo-frame
id="banner"
src="banner.htm"
data-turbo-permanent>
<cfmodule template="../banner_content.cfm" />
</turbo-frame>
<h1>
ColdFusion + Hotwire Banner Demo
</h1>
#thistag.generatedContent#
</body>
</html>
</cfoutput>
</cfsavecontent>
There are two things of note going on in this ColdFusion layout:
First, the <turbo-frame>
for our banner has both a src
attribute and the data-turbo-permanent
attribute. This means that the contents of the Turbo Frame will be requested (using the fetch()
API) after the page first loads. Then, on subsequent application visits (ie, navigation events), the already-rendered content of the Turbo Frame will be pulled directly from the page cache.
Second, we're using another ColdFusion custom tag (banner_content.cfm
) to render a static version of the banner. Rendering a static version of the banner has a two-fold effect: if Hotwire fails to load, ColdFusion will still make sure the banner is shown to the user; and, even if Hotwire does load, including a static version of the banner prevents a "Flash of Content" on the initial page load.
Our site-wide banner has two facets: the banner.cfm
file, which represents our banner "View"; and, our banner_content.cfm
ColdFusion custom tag, which is a reusable rendering of the visual contents of the banner. We use this ColdFusion custom tag both in the main page layout (show above) and the banner view (shown below).
Our banner view — which is really just a normal ColdFusion page — both renders the banner and provides a way to clear the banner state (using a query-string flag). When building a Hotwire-enhanced application, it's always important to ask yourself what happens if Hotwire didn't load? Or, if the given page was accessed directly (via the URL)? This often means that a ColdFusion page has two control-flow branches.
In this case, we're going to branch on whether or not we're operating inside a Turbo Frame. If a request to clear the banner state is a "top level" request, we just redirect the user back to the homepage (standard ColdFusion operating procedure). However, if a request to clear the state is a "Turbo Frame" request, we allow the page to render with an empty <turbo-frame>
element so that Turbo Drive will use it to hot-swap the contents of the persistent banner in the calling context.
<cfscript>
param name="request.context.clearBanner" type="boolean" default=false;
if ( request.context.clearBanner ) {
application.bannerText = "";
// If this request is not being scoped to a Turbo Frame (ie, Turbo Drive has not
// yet taken over the page navigation), let's redirect the user back to the
// homepage where ColdFusion will render the EMPTY banner.
if ( ! request.turbo.isFrame ) {
location( url = "index.htm", addToken = false );
}
}
</cfscript>
<cfoutput>
<!doctype html>
<html lang="en">
<!--- .... truncated .... --->
<body>
<!---
If this Banner page is rendered directly, we want to use "_top" as the target
so that the processing of the banner executes outside of any frame scoping
(and will redirect back to the main page).
--->
<turbo-frame id="banner" target="_top">
<cfmodule template="./banner_content.cfm" />
</turbo-frame>
</body>
</html>
</cfoutput>
As you can see, both the main page layout an our banner layout both use the banner_content.cfm
ColdFusion custom tag. This is just to remove repetition in the rendering of the actual HTML markup:
<cfoutput>
<cfif application.bannerText.len()>
<div class="banner">
<p class="banner__text">
#encodeForHtml( application.bannerText )#
</p>
<a href="banner.htm?clearBanner=true" class="banner__close">
Close
</a>
</div>
</cfif>
<!--- We do not expect this tag to have a body. --->
<cfexit method="exitTag" />
</cfoutput>
Notice that the banner content has a Close
call-to-action that just posts back to the banner view (from above) with the ?clearBanner=true
URL flag.
Now that we've seen where the banner is being rendered and how the banner state is being cleared, let's take a look at where the banner state is being set; because, as of now, it will never get shown in our ColdFusion application.
To mock-out a meaningful state change, I've created an "Upgrade" page. When the users chooses to upgrade their subscription, we're going to render the success message in our site-wide banner.
As with the banner view above, our upgrade page also requires us to think about progressive enhancement. Again, we must ask ourselves, what if Hotwire didn't load? What if this is just a normal ColdFusion page request?
And, again, this means that our processing will have some branching logic; this time, pivoting on a Turbo Stream context. If the current request doesn't support a Turbo Stream response, we just redirect the user back to the home page (standard ColdFusion operating procedure). However, if the request does support a Turbo Stream response, we're going to use our custom stream actions to reload both the top-level page and our persistent banner frame:
<cfscript>
if ( request.isPost ) {
application.bannerText = "Your upgrade has been processed!";
// If the current request can support a Turbo Stream response, it means that Turbo
// Drive has taken over the page navigation. In that case, we will execute the
// REDIRECT using our custom Turbo Stream "visit" action so that we can refresh
// both the top-level page and the banner's Turbo Frame.
if ( request.turbo.isStream ) {
include "./upgrade_stream.cfm";
exit;
// If this is a normal top-level page action, then let's redirect back to the main
// page as per usual and let ColdFusion render the newly-define banner content.
} else {
location( url = "index.htm", addToken = false );
}
}
</cfscript>
<cfmodule template="./tags/page.cfm" section="upgrade">
<cfoutput>
<h2>
Upgrade Your Subscription
</h2>
<form method="post" action="upgrade.htm">
<button type="submit">
Upgrade for <em>just</em> $9.99!
</button>
</form>
</cfoutput>
</cfmodule>
As you can see, if the current request supports a Turbo Stream response, instead of executing a location()
redirect, we include the upgrade_stream.cfm
template. This renders our <turbo-stream>
elements:
<cfcontent type="text/vnd.turbo-stream.html; charset=utf-8" />
<cfoutput>
<!--- Refresh the banner Turbo Frame. --->
<turbo-stream
action="visit"
data-url="banner.htm"
data-frame="banner">
</turbo-stream>
<!--- Redirect to the main page. --->
<turbo-stream
action="visit"
data-url="index.htm">
</turbo-stream>
</cfoutput>
Because our banner frame is marked as data-turbo-permanent
, simply redirecting the top-level page isn't enough - Turbo Drive will just pull the empty_ site-wide banner right out of the cache. As such, we have to force the banner frame to reload so that it can re-render with a now-populated <turbo-frame>
element.
ASIDE: We probably could have used one of the other built-in Turbo Stream actions (such as
replace
orupdate
) to modify the state of the persistent banner. But, I am finding the idea of reloading frames to be an easier mental jump from ColdFusion'slocation()
function.
Pulling all of this together, if we open up this ColdFusion application and upgrade our subscription, we can see the persistent Turbo Frame banner in action:
Transitioning my brain from thinking in terms of Single Page Applications (SPA) to thinking in terms of progressively enhanced Multi-Page Applications (MPA) is a bit of a journey. But, there is something deeply satisfying about seeing everything "just work" when you disable JavaScript or when you Meta+Click
links and have them open in a new tab. I am finding persistent Turbo Frames — such as with this banner or with my previous toast messages demo — are really helpful!
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 →